518 Commits

Author SHA1 Message Date
Nazar Kanaev
98934daee4 update docs 2023-08-15 14:35:42 +01:00
Nazar Kanaev
259474cae9 v2.4 2023-08-15 14:13:12 +01:00
Nazar Kanaev
1e65a7951b ui fixes 2023-08-14 22:01:13 +01:00
Nazar Kanaev
bed5640366 fix base font size 2023-08-14 21:25:47 +01:00
Hannes Braun
57ea83cf4f Use more recent images for GitHub Actions 2023-08-13 22:29:41 +01:00
Hannes Braun
219842d723 Update minimum required Go version to 1.17 2023-08-13 22:29:41 +01:00
Nazar Kanaev
a96fc101f2 paragraph margins 2023-08-06 12:40:54 +01:00
Nazar Kanaev
81a77ce0a4 update changelog 2023-05-22 22:28:15 +01:00
Adam Szkoda
9ed359f964 Protect a read from a map with a mutex 2023-05-22 22:24:57 +01:00
Nazar Kanaev
bc18557820 handle isPermalink in rss feeds 2023-05-20 23:26:22 +01:00
Nazar Kanaev
7d99edab8d update changelog 2023-05-16 11:12:54 +01:00
x2cf
32ca121520 fix item order 2023-05-16 11:09:57 +01:00
nkanaev
9f1a0534a3 Update readme.md 2023-04-18 14:59:39 +01:00
Nazar Kanaev
d2678be96d update dependencies 2023-03-18 20:48:10 +00:00
nkanaev
95ebbb9d13 update changelog 2023-02-10 09:41:50 +00:00
Kaloyan Petrov
0f6d4d639d Firefox uses altKey to switch tabs 2023-02-10 09:40:03 +00:00
Kaloyan Petrov
795a5d2cb4 Use JS event.code to support different language layouts 2023-02-10 09:39:45 +00:00
Nazar Kanaev
dd5f760606 update docs 2023-02-09 21:37:56 +00:00
Nazar Kanaev
58d6a46e36 increase hidden input height
this allows to show the whole selectgroup area
when scrolled via arrow keys.

as a side effect it makes pagination to work with arrow keys as well.
2023-02-09 21:34:23 +00:00
Nazar Kanaev
a8d7b86cdc do not render root folder input 2023-02-06 11:37:16 +00:00
Nazar Kanaev
aac3de7ca2 make active toolbar buttons prominent 2023-02-02 22:40:41 +00:00
Nazar Kanaev
de24659bae update changelog 2023-02-02 22:09:13 +00:00
Nazar Kanaev
632412c10e go fmt 2023-02-02 22:06:27 +00:00
Nazar Kanaev
012b58bbe4 make newly added feed searchable 2023-02-02 22:05:58 +00:00
nkanaev
c092842ee4 Update changelog.txt 2023-01-30 22:11:00 +00:00
Aaron Bieber
e4c1d01915 Add manifest.json for better mobile integration
manifest.json allows yarr to run in a more app-like mode on mobile
devices.

More info here: https://developer.mozilla.org/en-US/docs/Web/Manifest
2023-01-30 22:05:33 +00:00
Nazar Kanaev
ce07ddea92 update changelog 2023-01-29 14:30:36 +00:00
Nazar Kanaev
bd6322e533 set article view width limit 2023-01-29 14:28:43 +00:00
nkanaev
91da774286 Create todo.txt 2022-11-16 10:21:55 +00:00
nkanaev
e62906e63d fix readability edge case 2022-11-14 15:11:05 +00:00
nkanaev
56e5625adc Update readme.md 2022-09-27 19:53:29 +01:00
Jake Bauer
1ecf4b0bb4 fix linux download instruction
The instruction for downloading yarr for linux told the user to download yar-*-windows which is incorrect. The Linux package on the releases page is yarr-*-linux64.zip.
2022-09-27 19:42:08 +01:00
Nazar Kanaev
57d9421c7f update readme.md 2022-09-09 22:16:41 +01:00
nkanaev
a73188944d Update readme.md 2022-09-09 22:14:37 +01:00
Nazar Kanaev
97904cc0f3 linux installation script 2022-09-09 22:13:55 +01:00
Nazar Kanaev
f28f354992 update changelog 2022-08-21 13:32:18 +01:00
Nazar Kanaev
698f5d6d06 handle google url redirect in page crawler 2022-08-21 13:31:18 +01:00
nkanaev
b935a1c511 Update changelog.txt 2022-08-01 23:20:09 +01:00
Pierre Prinetti
10e6bfa5a0 Set auth without authfile
With this patch, it is possible to pass username and password directly
on the command line with:

```shell
yarr --auth 'username:password'
```

The corresponding environment variable comes handy with containers:
```shell
YARR_AUTH='username:password' yarr
```
2022-07-06 15:21:41 +01:00
nkanaev
f030a4075b refactoring 2022-07-04 16:03:50 +01:00
Pierre Prinetti
c9dd977600 Allow passwords with column
Before this patch, an `authfile` with multiple column symbols was not
valid.

After this patch, all characters after the first `:` constitute the
password, until EOL.
2022-07-04 15:21:09 +01:00
Pierre Prinetti
c1bcc0c517 Run go fmt
This patch is the result of running `go fmt ./...` with Go v1.16.15.
2022-07-04 15:20:49 +01:00
nkanaev
2a5692d9a7 fix scrolling issue with large font 2022-06-29 11:41:01 +01:00
quoing
a8d160f9b1 Fix concurrent map writes for FeedIcon cache 2022-06-21 16:12:59 +01:00
nkanaev
286cbff236 update favicon (2nd attempt) 2022-06-06 10:04:47 +01:00
nkanaev
fff0870d3b update changelog 2022-06-02 10:18:17 +01:00
nkanaev
fe22460c07 favicon tweaks 2022-06-02 10:16:56 +01:00
Nazar Kanaev
18f2789a5d oopsies 2022-06-01 21:04:42 +01:00
Nazar Kanaev
7f161a5408 update changelog 2022-06-01 21:02:13 +01:00
Nazar Kanaev
cba3fbc48c fix append (different behaviour in arm) 2022-06-01 20:51:00 +01:00
Nazar Kanaev
5e46f1480e add vendor 2022-06-01 20:49:50 +01:00
Nazar Kanaev
ead253c55f cross-compilation for ARMv7 2022-06-01 20:49:38 +01:00
Nazar Kanaev
6b8da92cb3 update link 2022-06-01 13:26:03 +01:00
Nazar Kanaev
a91f64ce9d docker build instructions 2022-06-01 13:24:39 +01:00
Nazar Kanaev
e1a6ccf133 update changelog 2022-05-03 20:59:24 +01:00
Nazar Kanaev
d2c034a850 v2.3 2022-05-03 20:40:39 +01:00
Nazar Kanaev
713930decc update changelog 2022-05-03 15:45:22 +01:00
Nazar Kanaev
ee2a825cf0 get rss link when atom link is present
found in: https://rss.nytimes.com/services/xml/rss/nyt/Arts.xml

when both rss and atom link elements are present, xml parser returns
empty string. provide default namespace to capture rss link properly.
2022-05-03 15:35:57 +01:00
Nazar Kanaev
8e9da86f83 nah 2022-04-09 16:06:19 +01:00
Nazar Kanaev
9eb49fd3a7 credits 2022-04-09 16:03:46 +01:00
Nazar Kanaev
684bc25b83 fix: load more items to prevent scroll lock 2022-04-09 15:58:33 +01:00
nkanaev
8ceab03cd7 fix text color in dark mode 2022-03-01 10:43:25 +00:00
Nazar Kanaev
34dad4ac8f systray: fix build flag 2022-02-16 14:04:04 +00:00
Nazar Kanaev
b40d930f8a credits 2022-02-15 22:12:32 +00:00
Nazar Kanaev
d4b34e900e update test 2022-02-15 22:04:16 +00:00
Nazar Kanaev
954b549029 update 2022-02-15 21:56:32 +00:00
Nazar Kanaev
fbd0b2310e update changelog 2022-02-14 20:33:28 +00:00
Nazar Kanaev
be7af0ccaf handle invalid chars in non-utf8 xml 2022-02-14 15:23:55 +00:00
Nazar Kanaev
18221ef12d use bytes.Buffer instead 2022-02-14 11:05:38 +00:00
Nazar Kanaev
4c0726412b do not build systray in linux 2022-02-14 00:56:03 +00:00
Nazar Kanaev
d7253a60b8 strip out invalid xml characters 2022-02-12 23:42:44 +00:00
Nazar Kanaev
2de3ddff08 fix test 2022-02-12 23:41:01 +00:00
Nazar Kanaev
830248b6ae store feed size 2022-02-10 22:14:47 +00:00
Nazar Kanaev
f8db2ef7ad delete old items based on feed size 2022-02-10 22:14:47 +00:00
Nazar Kanaev
109caaa889 cascade 2022-02-10 22:14:47 +00:00
Nazar Kanaev
d0b83babd2 initial work for smarter database cleanup 2022-02-10 22:14:47 +00:00
nkanaev
de3decbffd remove unused assets 2022-01-26 10:44:33 +00:00
nkanaev
c92229a698 update changelog 2022-01-24 16:56:55 +00:00
nkanaev
176852b662 credits 2022-01-24 16:52:29 +00:00
nkanaev
52cc8ecbbd fix encoding 2022-01-24 16:47:32 +00:00
nkanaev
e3e9542f1e fix page crawling encoding 2022-01-24 14:02:21 +00:00
nkanaev
b78c8bf8bf fix parsing opml with encoding 2022-01-24 13:10:30 +00:00
nkanaev
bff7476b58 refactoring 2022-01-24 12:50:52 +00:00
Nazar Kanaev
05f5785660 update promo.png 2022-01-18 14:36:05 +00:00
David Adi Nugroho
cb50aed89a add placeholder and autofocus to new feed url 2021-12-28 17:56:26 +00:00
Nazar Kanaev
df655aca5e remove todo 2021-11-20 22:42:33 +00:00
Nazar Kanaev
86853a87bf update changelog 2021-11-20 22:07:33 +00:00
Nazar Kanaev
e3109a4384 v2.2 2021-11-20 22:01:05 +00:00
Nazar Kanaev
eee8002d69 do not show loading icon after marking all articles read 2021-11-20 21:34:58 +00:00
Nazar Kanaev
92f11f7513 cleanup gitignore 2021-11-20 21:22:21 +00:00
nkanaev
5428e6be3a update changelog 2021-11-17 10:52:01 +00:00
Nazar Kanaev
1ad693f931 make selected feed/folder always visible 2021-11-11 22:04:27 +00:00
Nazar Kanaev
c2d88a7e3f update promo 2021-11-11 21:45:09 +00:00
nkanaev
3b29d737eb move theme selector to the main settings menu 2021-11-11 13:33:22 +00:00
nkanaev
fe178b8fc6 nope 2021-11-11 13:14:23 +00:00
nkanaev
cca742a1c2 run windows console fix 2021-11-11 09:51:56 +00:00
nkanaev
c7eddff118 make feed/folder settings available in all filter modes 2021-11-10 11:19:14 +00:00
nkanaev
cf30ed249f windows console fix 2021-11-10 11:06:30 +00:00
nkanaev
26b87dee98 remove html tags from titles 2021-11-10 10:54:12 +00:00
Karol Kosek
77c7f938f1 Autoselect current folder when adding a new feed
This patch makes categorising new feeds a bit more intuitive:
the selected folder (or feed within a folder) in the feed list
will automatically be selected when adding a new feed.
2021-11-10 10:20:14 +00:00
Nazar Kanaev
f98de9a0a5 update todo 2021-11-08 11:27:51 +00:00
Nazar Kanaev
6fa2b67024 todo 2021-10-25 16:24:18 +01:00
Nazar Kanaev
355e5feb62 update asset names 2021-08-16 12:57:28 +01:00
Nazar Kanaev
a7dd707062 update changelog 2021-08-16 12:56:59 +01:00
Nazar Kanaev
4de46a7bc5 v2.1 2021-08-16 12:49:26 +01:00
Nazar Kanaev
2c6fce3322 credits 2021-07-28 09:34:54 +01:00
Karol Kosek
19ecfcd0bc ParseRSS: accept any file with audio/ media type as podcast
There are some podcasts that use audio/opus files (mostly as an alternative,
but still), which makes the audio attachment not being displayed.

Instead of increasing the list of allowed formats (because audio/mp3 would be
quite useful on the list too), I guess it'd be better to give any audio/ media
type to the user-agent and let him worry about it. :^)
2021-07-28 09:31:27 +01:00
Nazar Kanaev
d575acfe80 fix env vars 2021-07-05 11:30:01 +01:00
Nazar Kanaev
d203d38de6 fix empty feed parsing 2021-07-01 14:10:22 +01:00
Nazar Kanaev
9f01f63613 credits 2021-06-29 16:46:12 +01:00
Nazar Kanaev
982c4ebbbc do not convert response to utf8 if charset is not set 2021-06-29 16:43:09 +01:00
Nazar Kanaev
0c5385cef3 update text 2021-06-07 10:07:20 +01:00
Nazar Kanaev
58f4e1f6c9 credits 2021-05-31 15:07:46 +01:00
Nazar Kanaev
6b7f69d5c0 fix tests 2021-05-31 15:06:39 +01:00
Nazar Kanaev
7aeb458ee5 fix pagination 2021-05-31 14:37:45 +01:00
Nazar Kanaev
7cfd3b3238 update help 2021-05-30 22:18:48 +01:00
Nazar Kanaev
55262d38fe credits 2021-05-30 21:53:01 +01:00
Nazar Kanaev
a45e29feb7 haha nkanaev you so funny 2021-05-30 21:33:33 +01:00
Nazar Kanaev
9f5fd3bb4d close article always shown 2021-05-30 21:29:27 +01:00
Farow
63f9d55903 update ui
- display full date when hovering over the age in the article list
- hide close article button on desktop layouts
- autofocus username on the login page
- hide the title on the settings/appearance dropdowns (still visible on the buttons)
2021-05-30 21:28:27 +01:00
Nazar Kanaev
8f36ae013e done 2021-05-28 10:28:23 +01:00
Nazar Kanaev
851aa1a136 rewrite icon crawling 2021-05-28 10:27:56 +01:00
Nazar Kanaev
f38dcfba3b cache feed icons 2021-05-27 13:16:03 +01:00
Nazar Kanaev
214c7aacfc fix refresh sync 2021-05-27 11:52:30 +01:00
nkanaev
eb9bfc57e2 Update readme.md 2021-05-25 15:34:49 +01:00
Nazar Kanaev
c072783c42 remove extra underscore from env vars 2021-05-22 21:51:39 +01:00
Nazar Kanaev
9d701678e1 option to log to a file 2021-05-22 21:50:22 +01:00
Nazar Kanaev
37ed856d8b fix iframe autoclosing 2021-05-13 22:37:02 +01:00
Nazar Kanaev
28f08ad42a responsive video iframe 2021-05-13 21:42:34 +01:00
Nazar Kanaev
da267a56ef todo 2021-05-06 22:34:21 +01:00
Nazar Kanaev
16e4cad9ad update dependencies 2021-05-03 22:21:01 +01:00
Nazar Kanaev
d13a04898e update changelog 2021-05-03 10:20:16 +01:00
Nazar Kanaev
ff39fbff70 default options from env vars 2021-05-03 10:16:14 +01:00
Nazar Kanaev
92c6aac49e todo 2021-04-27 14:35:39 +01:00
Nazar Kanaev
4ca81f90e9 update list 2021-04-27 14:34:44 +01:00
Nazar Kanaev
75e828cb4c update changelog 2021-04-26 15:26:23 +01:00
Nazar Kanaev
883214a740 more todo 2021-04-26 15:22:32 +01:00
Nazar Kanaev
36e359c881 fix headers 2021-04-26 15:16:26 +01:00
Nazar Kanaev
87b53fb8ec tweak 2021-04-26 15:14:03 +01:00
Nazar Kanaev
2ae62855cc fix importing certain opml files 2021-04-22 11:15:16 +01:00
Nazar Kanaev
19889c1457 v2.0 2021-04-18 10:52:35 +01:00
Nazar Kanaev
f9afbac258 update docs 2021-04-15 10:35:42 +01:00
Nazar Kanaev
e54df07a40 use rdf description 2021-04-15 10:29:35 +01:00
Nazar Kanaev
f8455236dc rdf date & content 2021-04-15 10:27:50 +01:00
Nazar Kanaev
d308bb64c2 rewrite items sql queries 2021-04-08 14:00:26 +01:00
Nazar Kanaev
077715f6c2 storage items test 2021-04-08 13:48:12 +01:00
Nazar Kanaev
bfe7bfdbd5 test with foreign keys 2021-04-08 11:00:17 +01:00
Nazar Kanaev
1013cd1122 rename Errorf -> Printf 2021-04-07 21:09:34 +01:00
Nazar Kanaev
3e57ccc999 import missing package 2021-04-07 21:04:19 +01:00
Nazar Kanaev
5f23f8be89 credits 2021-04-07 21:02:44 +01:00
Nazar Kanaev
211b1456c7 no more null dates 2021-04-07 20:54:39 +01:00
Nazar Kanaev
3d9c9d03cc refresh ui hacks 2021-04-07 15:47:47 +01:00
Nazar Kanaev
1ea8160f7d remove todo 2021-04-07 15:39:37 +01:00
Nazar Kanaev
2c12875199 fix 2021-04-07 15:38:44 +01:00
Nazar Kanaev
bb0b575eca let's call it done 2021-04-07 15:31:39 +01:00
Nazar Kanaev
e51ccb723e dropdown icons 2021-04-07 15:29:29 +01:00
Nazar Kanaev
96796702cf select the newly added feed 2021-04-07 15:05:11 +01:00
Nazar Kanaev
fd44c98cd0 nothing can be done
the website serves content without charset/encoding information
in http headers. in the absence of that info charset.DetermineEncoding
defaults to cp-1252, but the actual content served is utf-8.

which explains why, say, ’ shows up as ’

see:
    https://stackoverflow.com/questions/2477452/%C3%A2%E2%82%AC-showing-on-page-instead-of
2021-04-07 14:54:57 +01:00
Nazar Kanaev
c2a28bcadf allow svg in sanitizer 2021-04-07 14:34:57 +01:00
Nazar Kanaev
30e6afb096 update changelog 2021-04-07 14:31:06 +01:00
Nazar Kanaev
882be1dbf6 more todos 2021-04-07 10:58:17 +01:00
Nazar Kanaev
8764891b80 menu fix 2021-04-07 10:39:09 +01:00
Nazar Kanaev
fbb0dfed47 remove bom 2021-04-07 10:25:30 +01:00
Nazar Kanaev
42b36965c5 remove error prefixes 2021-04-06 21:48:09 +01:00
Nazar Kanaev
e326c7a0fb show feed errors on the 2nd pane 2021-04-06 21:37:32 +01:00
Nazar Kanaev
9fae33f57b feed/folder dropdown titles 2021-04-06 20:34:20 +01:00
Nazar Kanaev
a397d2013d dropdown tweaks 2021-04-06 20:30:42 +01:00
Nazar Kanaev
f65aadb055 remove manage modal, edit via dropdown 2021-04-06 20:12:52 +01:00
Nazar Kanaev
c825f8864f drop description field usage 2021-04-06 11:50:15 +01:00
Nazar Kanaev
2edf11a36a hopefully gone 2021-04-06 11:35:26 +01:00
Nazar Kanaev
2df2f41516 gofmt -s -w . 2021-04-06 08:43:15 +01:00
Nazar Kanaev
614dcc8975 todo 2021-04-05 21:37:09 +01:00
Nazar Kanaev
6acf9af887 remove item.date_updated, item.description & item.author fields in storage 2021-04-05 21:30:07 +01:00
Nazar Kanaev
ecdfcb5017 test command 2021-04-05 21:04:48 +01:00
Nazar Kanaev
144fc1606a remove feed hacks from storage 2021-04-05 20:59:15 +01:00
Nazar Kanaev
9919d72be0 more tests 2021-04-05 20:35:30 +01:00
Nazar Kanaev
9e95f71de8 bump 2021-04-05 19:06:09 +01:00
Nazar Kanaev
09bfc47ef0 start storage tests 2021-04-05 19:02:38 +01:00
Nazar Kanaev
2e5ccc3158 delete anchor.png 2021-04-05 14:15:59 +01:00
Nazar Kanaev
70481dff73 update changelog 2021-04-05 13:19:42 +01:00
Nazar Kanaev
cc12f27ce3 no submodule - no problem 2021-04-05 12:31:47 +01:00
Nazar Kanaev
c36d82636d squeeze 2021-04-05 11:57:59 +01:00
Nazar Kanaev
b123753d65 readability keyboard shortcut 2021-04-05 11:49:16 +01:00
Nazar Kanaev
f590c358d2 do not strip out content inside table & code 2021-04-05 11:04:24 +01:00
Nazar Kanaev
fa2fad0ff6 cleanup 2021-04-05 10:01:20 +01:00
Nazar Kanaev
d8aab7acae done 2021-04-04 21:47:35 +01:00
Nazar Kanaev
63ad971890 unsset audio/image if present in the content 2021-04-04 21:31:25 +01:00
Nazar Kanaev
0828d6782e extract date parser to a new file 2021-04-04 20:45:13 +01:00
Nazar Kanaev
cf5856bdf7 set missing times 2021-04-04 20:42:52 +01:00
Nazar Kanaev
34edfc0727 router: handle urls without base properly 2021-04-03 20:52:17 +01:00
Nazar Kanaev
a1b1686d3b update todo 2021-04-03 20:28:33 +01:00
Nazar Kanaev
c5abf8f9d0 fix import 2021-04-02 22:36:02 +01:00
Nazar Kanaev
9a5af089eb update todo 2021-04-02 22:35:02 +01:00
Nazar Kanaev
9edd865bf4 remove whitespace in extracttext 2021-04-02 22:26:45 +01:00
Nazar Kanaev
e50c7e1a51 handle html type atom text 2021-04-02 22:26:45 +01:00
Nazar Kanaev
8967936fb6 done 2021-04-02 22:26:45 +01:00
Nazar Kanaev
fa92ea16b0 search for icons only during startup and after adding feeds 2021-04-02 22:26:45 +01:00
Nazar Kanaev
c8d6363677 already done 2021-04-02 22:26:45 +01:00
Nazar Kanaev
b082c3e048 gzip middleware 2021-04-02 22:26:45 +01:00
Nazar Kanaev
82fdb3be6c don't serve templates 2021-04-02 22:26:45 +01:00
Nazar Kanaev
d7ba203f28 rewrite basepath 2021-04-02 22:26:45 +01:00
Nazar Kanaev
1cba53f7fb done 2021-04-02 22:26:45 +01:00
Nazar Kanaev
0a0db68905 feedburner 2021-04-02 22:26:45 +01:00
Nazar Kanaev
3512350a22 ditch fetch polyfill, cleanup index.html 2021-04-02 22:26:45 +01:00
Nazar Kanaev
8b2a9d8f20 rename keybindings -> key 2021-04-02 22:26:45 +01:00
Nazar Kanaev
34b50d388a ditch vue-lazyload 2021-04-02 22:26:45 +01:00
Nazar Kanaev
cd412a4ac5 remove css map file 2021-04-02 22:26:45 +01:00
Nazar Kanaev
7fb6271e56 update todo 2021-04-02 22:26:45 +01:00
Nazar Kanaev
2cd815d9cd youtube/vimeo iframes 2021-04-02 22:26:45 +01:00
Nazar Kanaev
0a6e621c02 update todo 2021-04-02 22:26:45 +01:00
Nazar Kanaev
10c656a3b6 done 2021-04-02 22:26:45 +01:00
Nazar Kanaev
0ea313d945 fix 2021-04-02 22:26:45 +01:00
Nazar Kanaev
1f02bde5e1 switch to the new whitelist 2021-04-02 22:26:45 +01:00
Nazar Kanaev
3e0c784744 add whitelist from dompurify 2021-04-02 22:26:45 +01:00
Nazar Kanaev
528df7fb4a reorganizing server-related packages 2021-04-02 22:26:45 +01:00
Nazar Kanaev
b04e8c1e93 reorganizing content-related packages 2021-04-02 22:26:45 +01:00
Nazar Kanaev
0b8bf50204 nah 2021-04-02 22:26:45 +01:00
Nazar Kanaev
f43924c17b change font selection 2021-04-02 22:26:45 +01:00
Nazar Kanaev
0f519b7202 theme fixes 2021-04-02 22:26:45 +01:00
Nazar Kanaev
c74eeff790 provide settings prior to js rendering 2021-04-02 22:26:45 +01:00
Nazar Kanaev
e7b645a68a fix item status update 2021-04-02 22:26:45 +01:00
Nazar Kanaev
fc0bfd29db space 2021-04-02 22:26:45 +01:00
Nazar Kanaev
8950181f21 update doc 2021-04-02 22:26:45 +01:00
Nazar Kanaev
70e592c979 bureaucracy 2021-04-02 22:26:45 +01:00
Nazar Kanaev
401668e413 finally getting rid of goquery in readability 2021-04-02 22:26:45 +01:00
Nazar Kanaev
37ddde1765 still rewriting readability 2021-04-02 22:26:45 +01:00
Nazar Kanaev
82586dedff rewriting readability 2021-04-02 22:26:45 +01:00
Nazar Kanaev
ac36892150 reader utility 2021-04-02 22:26:44 +01:00
Nazar Kanaev
c958ee9116 rewriting readability 2021-04-02 22:26:44 +01:00
Nazar Kanaev
e5920259b6 start rewriting readability 2021-04-02 22:26:44 +01:00
Nazar Kanaev
8c44d2fc87 todos 2021-04-02 22:26:44 +01:00
Nazar Kanaev
332ee0e6b5 cleanup 2021-04-02 22:26:44 +01:00
Nazar Kanaev
3ae17171e2 server-side item sanitization 2021-04-02 22:26:44 +01:00
Nazar Kanaev
493a4262b1 cleanup sanitizer 2021-04-02 22:26:44 +01:00
Nazar Kanaev
485587825c switch to server-side readability 2021-04-02 22:26:44 +01:00
Nazar Kanaev
a83d43a5b1 borrow miniflux code 2021-04-02 22:26:44 +01:00
Nazar Kanaev
71f81a3802 fix title 2021-04-02 22:26:44 +01:00
Nazar Kanaev
6481c97645 ditch bootstrap-vue & popper 2021-04-02 22:26:44 +01:00
Nazar Kanaev
fa40a79d50 css cleanup 2021-04-02 22:26:44 +01:00
Nazar Kanaev
169d579400 ditch b-modal 2021-04-02 22:26:44 +01:00
Nazar Kanaev
430f300140 ditch v-b-tooltip 2021-04-02 22:26:44 +01:00
Nazar Kanaev
a9b450db03 unfocus search on enter 2021-04-02 22:26:44 +01:00
Nazar Kanaev
89ce8df0e3 item status change tweaks 2021-04-02 22:26:44 +01:00
Nazar Kanaev
aa015b78c0 ui fixes 2021-04-02 22:26:44 +01:00
Nazar Kanaev
3ed1b3e612 done 2021-04-02 22:26:44 +01:00
Nazar Kanaev
7fb0d3833e redesign appearance dropdown 2021-04-02 22:26:44 +01:00
Nazar Kanaev
2da616d4ff settings redesign 2021-04-02 22:26:44 +01:00
Nazar Kanaev
0b3d7faf9f dropdown tweaks + use dropdown instead of popover 2021-04-02 22:26:44 +01:00
Nazar Kanaev
b3ba912566 ditch bootstrap-vue dropdown 2021-04-02 22:26:44 +01:00
Nazar Kanaev
36bc84d99a increase lookup length 2021-04-02 22:26:44 +01:00
Nazar Kanaev
f126247262 untitle in content 2021-04-02 22:26:44 +01:00
Nazar Kanaev
b145b00f8e add vimeo to list 2021-04-02 22:26:44 +01:00
Nazar Kanaev
7dbfecdba1 extract thumbnails from vimeo feeds 2021-04-02 22:26:44 +01:00
Nazar Kanaev
ad693aaf02 content image tweaks 2021-04-02 22:26:44 +01:00
Nazar Kanaev
fafa6286d4 parser fixes 2021-04-02 22:26:44 +01:00
Nazar Kanaev
cc51fe01c2 give priority to content:encoded 2021-04-02 22:26:44 +01:00
Nazar Kanaev
91deb41d5b store podcast url 2021-04-02 22:26:44 +01:00
Nazar Kanaev
2e4082df77 update doc 2021-04-02 22:26:44 +01:00
Nazar Kanaev
51cbdea31f podcasts 2021-04-02 22:26:44 +01:00
Nazar Kanaev
5335863488 update formats doc 2021-04-02 22:26:44 +01:00
Nazar Kanaev
c2e1926741 show item image 2021-04-02 22:26:44 +01:00
Nazar Kanaev
37a679fc80 update formats doc 2021-04-02 22:26:44 +01:00
Nazar Kanaev
1be79d922b feedtest: support local files 2021-04-02 22:26:44 +01:00
Nazar Kanaev
6685bce51c extract data from media elements 2021-04-02 22:26:44 +01:00
Nazar Kanaev
fe1a1987bd translate urls 2021-04-02 22:26:44 +01:00
Nazar Kanaev
80402943a1 wrap in charset 2021-04-02 22:26:44 +01:00
Nazar Kanaev
b40fe94147 refactor crawler 2021-04-02 22:26:44 +01:00
Nazar Kanaev
e0e6166cdf fix feed sniff reader 2021-04-02 22:26:44 +01:00
Nazar Kanaev
a2bfd1682b rewrite worker 2021-04-02 22:26:44 +01:00
Nazar Kanaev
1f393faf79 move checker tool to bin 2021-04-02 22:26:44 +01:00
Nazar Kanaev
c469749eaa rename packaages 2021-04-02 22:26:44 +01:00
Nazar Kanaev
e0009e4267 feed checker tool 2021-04-02 22:26:44 +01:00
Nazar Kanaev
24a06faa3c fix dates 2021-04-02 22:26:44 +01:00
Nazar Kanaev
9ede816078 rewrite crawler 2021-04-02 22:26:44 +01:00
Nazar Kanaev
646519e074 add charsetreader to xmlreader 2021-04-02 22:26:44 +01:00
Nazar Kanaev
454eff0155 done 2021-04-02 22:26:44 +01:00
Nazar Kanaev
ebd7f2929c drop gofeed 2021-04-02 22:26:44 +01:00
Nazar Kanaev
5b36530f67 switch to internal feed parser 2021-04-02 22:26:44 +01:00
Nazar Kanaev
c91b439878 tweaks 2021-04-02 22:26:44 +01:00
Nazar Kanaev
7d61f705bf url fixer 2021-04-02 22:26:44 +01:00
Nazar Kanaev
9fa8b8440a go fmt ./... 2021-04-02 22:26:44 +01:00
Nazar Kanaev
9e8837b37d tweaks 2021-04-02 22:26:44 +01:00
Nazar Kanaev
7ca9415322 feed refactoring 2021-04-02 22:26:44 +01:00
Nazar Kanaev
e78c028d20 feed dump 2021-04-02 22:26:44 +01:00
Nazar Kanaev
cbc75047b8 update formats 2021-04-02 22:26:44 +01:00
Nazar Kanaev
cc6f6d91e1 rss parser 2021-04-02 22:26:44 +01:00
Nazar Kanaev
70e9e1ed3a moar links 2021-04-02 22:26:44 +01:00
Nazar Kanaev
efafdbebaa remove feed_url references 2021-04-02 22:26:44 +01:00
Nazar Kanaev
19967ce37c leave comments 2021-04-02 22:26:44 +01:00
Nazar Kanaev
ce3d6fba37 fixes 2021-04-02 22:26:44 +01:00
Nazar Kanaev
3e14716fc6 learn more about formats 2021-04-02 22:26:44 +01:00
Nazar Kanaev
43620cd9b6 basic rdf test 2021-04-02 22:26:44 +01:00
Nazar Kanaev
e819140f36 remove unneeded rss fields 2021-04-02 22:26:44 +01:00
Nazar Kanaev
f28f0eac1a rdf xml structs 2021-04-02 22:26:44 +01:00
Nazar Kanaev
ff4abb8dfe another nice parser 2021-04-02 22:26:44 +01:00
Nazar Kanaev
e2efaddfed rss xml structs 2021-04-02 22:26:44 +01:00
Nazar Kanaev
b1db6c6fb1 add links 2021-04-02 22:26:44 +01:00
Nazar Kanaev
279fc469ab basic atom 1.0 parser 2021-04-02 22:26:44 +01:00
Nazar Kanaev
d185fb6dd7 handle base url later 2021-04-02 22:26:44 +01:00
Nazar Kanaev
3a667a3809 add date parser 2021-04-02 22:26:44 +01:00
Nazar Kanaev
a895775f81 basic json feed parser 2021-04-02 22:26:44 +01:00
Nazar Kanaev
0a9beddef9 update formats 2021-04-02 22:26:44 +01:00
Nazar Kanaev
29528e40b0 gather info 2021-04-02 22:26:44 +01:00
Nazar Kanaev
aaf0b702a3 remove dead code 2021-04-02 22:26:44 +01:00
Nazar Kanaev
9f376db0f4 gofmt 2021-04-02 22:26:44 +01:00
Nazar Kanaev
391ce61362 opml tweaks & fixes 2021-04-02 22:26:44 +01:00
Nazar Kanaev
62e2ca4c16 switch to new opml 2021-04-02 22:26:44 +01:00
Nazar Kanaev
ea5af73901 remove print 2021-04-02 22:26:44 +01:00
Nazar Kanaev
7d7feda319 rewrite opml 2021-04-02 22:26:44 +01:00
Nazar Kanaev
1c810f68f8 done 2021-04-02 22:26:44 +01:00
Nazar Kanaev
8528a80d7e minor 2021-04-02 22:26:44 +01:00
Nazar Kanaev
dd0bf1a012 done 2021-04-02 22:26:44 +01:00
Nazar Kanaev
e0c4752bbf drop direct goquery dependency 2021-04-02 22:26:44 +01:00
Nazar Kanaev
c896440525 find favicons 2021-04-02 22:26:44 +01:00
Nazar Kanaev
1f042a8434 separate package for crawler 2021-04-02 22:26:44 +01:00
Nazar Kanaev
fc3383946d crawl 2021-04-02 22:26:44 +01:00
Nazar Kanaev
4abbebf5e9 done 2021-04-02 22:26:44 +01:00
Nazar Kanaev
eb0ad7f22e move authmiddleware to auth package 2021-04-02 22:26:44 +01:00
Nazar Kanaev
0b1c90718d cleanup 2021-04-02 22:26:44 +01:00
Nazar Kanaev
47597b2b7c simplicity 2021-04-02 22:26:44 +01:00
Nazar Kanaev
f3c55ba5f2 basepath fixes 2021-04-02 22:26:44 +01:00
Nazar Kanaev
5e453e3227 css tweak 2021-04-02 22:26:43 +01:00
Nazar Kanaev
9bf7f45354 router base 2021-04-02 22:26:43 +01:00
Nazar Kanaev
c8bc511e04 oops 2021-04-02 22:26:43 +01:00
Nazar Kanaev
85a114e591 login page tweaks 2021-04-02 22:26:43 +01:00
Nazar Kanaev
73b7144394 auth middleware basepath tweaks 2021-04-02 22:26:43 +01:00
Nazar Kanaev
e9f6a0a1d2 auth middleware 2021-04-02 22:26:43 +01:00
Nazar Kanaev
dfb32d4ebe extract worker & crawler from server 2021-04-02 22:26:43 +01:00
Nazar Kanaev
682b0c1729 done 2021-04-02 22:26:43 +01:00
Nazar Kanaev
e79abb69eb include systray 2021-04-02 22:26:43 +01:00
Nazar Kanaev
514ed02693 remove example file 2021-04-02 22:26:43 +01:00
Nazar Kanaev
ff3241bd57 context int shorthands 2021-04-02 22:26:43 +01:00
Nazar Kanaev
76937bedc9 switch to context.JSON 2021-04-02 22:26:43 +01:00
Nazar Kanaev
1e65da9aa4 opml package 2021-04-02 22:26:43 +01:00
Nazar Kanaev
0d49377879 auth package 2021-04-02 22:26:43 +01:00
Nazar Kanaev
1a490a8e7a switch to the new router 2021-04-02 22:26:43 +01:00
Nazar Kanaev
e53265472f rename handler -> server 2021-04-02 22:26:43 +01:00
Nazar Kanaev
66fdbef90b test router 2021-04-02 22:26:43 +01:00
Nazar Kanaev
80482e70a8 fix router 2021-04-02 22:26:43 +01:00
Nazar Kanaev
f214c3166b router servehttp 2021-04-02 22:26:43 +01:00
Nazar Kanaev
4a4303afef router package 2021-04-02 22:26:43 +01:00
Nazar Kanaev
cc7bdc5b76 switch to the standard logger 2021-04-02 22:26:43 +01:00
Nazar Kanaev
3539433a9d hierarchical feed list in the manage modal 2021-03-18 12:04:52 +00:00
Nazar Kanaev
54cb821ae9 smooth article scrolling 2021-03-18 12:03:02 +00:00
Nazar Kanaev
4924dcfd12 update changelog 2021-03-16 11:08:43 +00:00
Nazar Kanaev
d84e76d07c update todo 2021-03-16 09:50:12 +00:00
Nazar Kanaev
eca215d044 update todo 2021-03-16 09:48:15 +00:00
Nazar Kanaev
721de3fba6 article scroll keybindings 2021-03-16 00:01:53 +00:00
Nazar Kanaev
e7fa98008d todo 2021-03-12 14:03:33 +00:00
Nazar Kanaev
923fbc54b8 update changelog 2021-03-12 13:28:33 +00:00
Vasiliy Faronov
ff39f90abd fix serving static files with -base
I deleted the commented-out block because it hasn't been touched
in half a year now.
2021-03-12 13:26:16 +00:00
Nazar Kanaev
db30fa3c5e update todo 2021-03-11 23:36:42 +00:00
Nazar Kanaev
26c902d551 v1.4 2021-03-11 23:19:39 +00:00
Nazar Kanaev
e9739f191e serve with local.db 2021-03-11 16:12:08 +00:00
Nazar Kanaev
fa2b97242d storage fixes 2021-03-10 22:13:03 +00:00
Nazar Kanaev
f1332d4200 1-based numbering for migrations 2021-03-10 20:03:01 +00:00
Nazar Kanaev
d34c6df673 todo 2021-03-10 13:08:41 +00:00
Nazar Kanaev
77ddde1c8a let's be honest 2021-03-10 12:47:19 +00:00
Nazar Kanaev
d39bdd7ef2 move to doc 2021-03-09 13:20:42 +00:00
Nazar Kanaev
473b38ebdf rationale dump 2021-03-09 13:19:41 +00:00
Nazar Kanaev
59f546804e no overscroll ಠ_ಠ 2021-03-09 11:45:27 +00:00
Nazar Kanaev
a7f6bdc0ba fix package header 2021-03-08 15:29:25 +00:00
Nazar Kanaev
7652f44b79 fix makefile 2021-03-08 15:25:38 +00:00
Nazar Kanaev
5b3adb2c7e fix yaml 2021-03-08 15:22:06 +00:00
Nazar Kanaev
ed8f2ab96f update github build workflow 2021-03-08 15:19:35 +00:00
Nazar Kanaev
5fa27a99da update github build workflow 2021-03-08 15:10:44 +00:00
Nazar Kanaev
fcdb97f079 update docs 2021-03-05 15:29:47 +00:00
Nazar Kanaev
f2994bc6d0 remove shortcuts info from readme 2021-03-03 13:02:57 +00:00
Nazar Kanaev
ffa0bc1733 credits 2021-03-03 12:56:00 +00:00
Nazar Kanaev
1198005803 update changelog 2021-03-03 12:55:28 +00:00
Nazar Kanaev
e3820d1c8e shortcut: open item.link 2021-03-03 12:53:55 +00:00
Nazar Kanaev
af2a01eea2 downgrade keybindings.js to es5 2021-03-03 12:53:55 +00:00
Nazar Kanaev
5ec89f4041 keybindings tweaks & fixes 2021-03-03 12:53:55 +00:00
Nazar Kanaev
57efbe4ebc update index.html 2021-03-03 12:53:55 +00:00
Nazar Kanaev
7b558c529b cache only embedded templates 2021-03-03 12:53:55 +00:00
Nazar Kanaev
caabd069d6 keybinding help styling 2021-03-03 12:52:51 +00:00
Duarte Dias
28ad0345f3 create modal for keyboard shortcuts 2021-03-03 12:52:50 +00:00
Duarte Dias
d89ae3a2bc add keyboard shortcut information in readme.md 2021-03-03 12:52:41 +00:00
Duarte Dias
bd12096e74 implement 'read all' and 'show' shortcuts
(A) -> show All
(U) -> show Unread
(S) -> show Starred
(R) -> mark all read
2021-03-03 12:52:41 +00:00
Duarte Dias
1cdde4a6b8 Revert extensions changes 2021-03-03 12:52:41 +00:00
Duarte Dias
79704d81c2 include "All Feeds" in keyboard navigation 2021-03-03 12:52:41 +00:00
Duarte Dias
4225e06db9 add navigation and general keybindings 2021-03-03 12:52:41 +00:00
Duarte Dias
fc5da31acf listen to desired keyboard events 2021-03-03 12:52:30 +00:00
Duarte Dias
f2db0309ac embed youtube videos on feed 2021-03-03 12:52:01 +00:00
nkanaev
48dd1a28f8 Update readme.md 2021-03-02 16:24:39 +00:00
Nazar Kanaev
ec04bd99d0 usage instructions 2021-03-02 16:20:49 +00:00
Nazar Kanaev
899fe62b85 update changelog 2021-03-02 11:28:04 +00:00
Nazar Kanaev
512245f54f update doc 2021-03-02 11:18:53 +00:00
Nazar Kanaev
e1cfb04f98 podcasts 2021-03-02 11:18:32 +00:00
Nazar Kanaev
4accfad266 update doc 2021-03-01 22:32:58 +00:00
Nazar Kanaev
bf5351d551 update docs 2021-03-01 22:23:20 +00:00
Nazar Kanaev
ef44706957 migrations 2021-03-01 22:18:15 +00:00
Nazar Kanaev
5b0b47635d remove open-golang dependency 2021-03-01 15:37:06 +00:00
Nazar Kanaev
93eeef0131 update running 2021-02-28 20:31:49 +00:00
Nazar Kanaev
089f0ee30b update install.md 2021-02-28 20:29:16 +00:00
Nazar Kanaev
0565e74cfa update readme.md 2021-02-28 20:28:53 +00:00
Nazar Kanaev
2270f716f2 move build instructions to readme 2021-02-28 20:27:22 +00:00
Nazar Kanaev
a850d83b33 sqlite on delete actions 2021-02-28 00:35:19 +00:00
Nazar Kanaev
7560b8167b fix makefile 2021-02-28 00:26:24 +00:00
Nazar Kanaev
5d626b3d07 todo 2021-02-27 21:19:21 +00:00
Nazar Kanaev
0997440f32 go mod tidy 2021-02-26 14:02:14 +00:00
Nazar Kanaev
62e8d3758a update build doc 2021-02-26 13:53:43 +00:00
Nazar Kanaev
3eb1820759 fix makefile 2021-02-26 13:49:37 +00:00
Nazar Kanaev
bd734cb918 rename assets path 2021-02-26 13:49:37 +00:00
Nazar Kanaev
6a643f7ca7 add gofeed as submodule 2021-02-26 13:49:37 +00:00
Nazar Kanaev
adef7b76c9 rename go packages 2021-02-26 13:49:37 +00:00
Nazar Kanaev
1037a8de0d rename scripts -> bin 2021-02-26 13:49:37 +00:00
Nazar Kanaev
3fac9bb1bd move packages to src 2021-02-26 13:49:37 +00:00
nkanaev
d825ce9bdf links in readme 2021-02-26 13:14:02 +00:00
Nazar Kanaev
25c6a151ce minify readability.js 2021-02-26 13:06:03 +00:00
Nazar Kanaev
1a7add1890 done 2021-02-26 12:51:11 +00:00
Nazar Kanaev
fca4194946 switch icons to embed 2021-02-26 12:50:29 +00:00
Nazar Kanaev
121101de9d switch assets to embed 2021-02-26 12:33:35 +00:00
Nazar Kanaev
a3146926b1 update docs 2021-02-25 16:51:24 +00:00
Nazar Kanaev
81df244d41 refresh stats after moving feed 2021-02-24 13:08:44 +00:00
Nazar Kanaev
068b4030f5 content pre styling 2021-02-20 15:17:26 +00:00
Nazar Kanaev
0916f1179e v1.3 2021-02-18 10:28:55 +00:00
Nazar Kanaev
6a5be593df credits 2021-02-12 10:59:13 +00:00
Nazar Kanaev
7fd1ef80c5 update changelog 2021-02-12 10:56:43 +00:00
Nazar Kanaev
26313f7842 add login.html to bundle 2021-02-12 10:53:08 +00:00
Nazar Kanaev
ce1914419a fix: import not working if auth enabled 2021-02-12 10:46:14 +00:00
Nazar Kanaev
6a828532cb logout 2021-02-12 10:31:00 +00:00
Nazar Kanaev
e8a002d535 update changelog 2021-02-11 21:24:37 +00:00
Nazar Kanaev
4f1e029d0b v1.2 2021-02-11 20:57:20 +00:00
Nazar Kanaev
9b93a959e5 fix: deleting item when all feeds is selected causes ui crash 2021-02-11 20:56:27 +00:00
Nazar Kanaev
68d269658f mobile/tablet layout tweaks 2021-02-11 20:37:30 +00:00
Nazar Kanaev
875b87b0d6 non-draggable in mobile/tablet resolution 2021-02-11 20:37:30 +00:00
Nazar Kanaev
3e79cd7944 todo entry 2021-02-05 11:15:07 +00:00
Nazar Kanaev
0ea8972ede update todo 2021-02-03 21:30:25 +00:00
Nazar Kanaev
a7113addd0 public todo 2021-02-03 21:24:06 +00:00
Nazar Kanaev
84df847898 platform notes 2021-02-03 21:18:09 +00:00
Nazar Kanaev
333d9373dd break long words 2021-02-01 21:19:17 +00:00
Nazar Kanaev
eb90c5b9aa attribution 2021-01-29 13:33:31 +00:00
Nazar Kanaev
3371e1afff add changelog 2021-01-27 15:46:57 +00:00
Nazar Kanaev
8aafb1b729 open urls with basepath 2021-01-27 15:46:38 +00:00
hcl
1a0db29aa6 prevent route leak 2021-01-25 21:02:29 +00:00
hcl
52073e7e81 make route handling better 2021-01-25 21:02:29 +00:00
hcl
4f79c919f0 Add non-root url path support 2021-01-25 21:02:29 +00:00
Nazar Kanaev
63b265fa04 move hacking.md 2021-01-19 23:35:39 +00:00
Nazar Kanaev
6b01d9d7b9 linux installation guide 2021-01-19 23:35:29 +00:00
Nazar Kanaev
20a0a6724a open flag 2021-01-18 23:16:13 +00:00
Nazar Kanaev
e79cb9e6e0 done 2021-01-07 12:06:40 +00:00
Nazar Kanaev
d7ddcc04b5 show feed errors 2021-01-07 12:06:14 +00:00
Nazar Kanaev
99684a4b2f store feed errors 2021-01-07 11:32:42 +00:00
Nazar Kanaev
6a6153ca48 fix unsafe methods 2021-01-04 14:46:36 +00:00
Nazar Kanaev
23a4ff3af6 https 2021-01-04 14:40:25 +00:00
Nazar Kanaev
d6c2ba5812 simple csrf protection 2021-01-04 14:15:28 +00:00
Nazar Kanaev
fa0237b546 remove todo 2021-01-04 14:04:48 +00:00
Nazar Kanaev
9fcaad6b2f hmac-based auth 2021-01-04 14:03:51 +00:00
Nazar Kanaev
edc7d56219 log db file 2021-01-04 11:51:31 +00:00
Nazar Kanaev
d0a2b80ecc logout ui 2020-12-16 16:49:35 +00:00
Nazar Kanaev
e2d80af81d login 2020-12-16 16:24:50 +00:00
Nazar Kanaev
eccd383c1c rename param auth -> auth-file 2020-12-16 15:58:32 +00:00
Nazar Kanaev
db7a178a8d remove fever code 2020-12-16 00:02:45 +00:00
Nazar Kanaev
62e0caa950 drop fever support 2020-12-15 23:49:26 +00:00
Nazar Kanaev
46d8c98aff remove slash 2020-11-10 23:42:17 +00:00
Nazar Kanaev
05634ebdb7 rename skipauth -> manualauth 2020-11-10 23:34:38 +00:00
Nazar Kanaev
0e2da62081 login page 2020-11-03 21:54:55 +00:00
Nazar Kanaev
94d1659ad5 remove prints 2020-11-03 21:51:16 +00:00
Nazar Kanaev
0745c92e9a auth parameter 2020-11-03 20:58:26 +00:00
Nazar Kanaev
7c06952a7d rename vars 2020-11-03 20:42:54 +00:00
Nazar Kanaev
e2d8ca3506 fever api fixes 2020-11-01 15:57:52 +00:00
Nazar Kanaev
4f20f537c0 remove todo 2020-11-01 15:51:15 +00:00
Nazar Kanaev
a0b42b27b3 fever: handle invalid requests 2020-11-01 15:50:52 +00:00
Nazar Kanaev
288fa3979a refactoring 2020-11-01 15:49:04 +00:00
Nazar Kanaev
e4cc96ef09 fever api feed last_updated_on_time 2020-11-01 15:45:58 +00:00
Nazar Kanaev
60a947f131 fever api last_refreshed_on_time 2020-11-01 15:33:24 +00:00
Nazar Kanaev
0226c8da23 fever item endpoint max_id parameter 2020-11-01 15:27:25 +00:00
Nazar Kanaev
b0364087ad fever write api [wip] 2020-10-25 16:29:26 +00:00
Nazar Kanaev
40a9773beb fever hot links stub 2020-10-22 22:30:44 +01:00
Nazar Kanaev
790a275443 fever with_ids fix 2020-10-22 22:24:50 +01:00
Nazar Kanaev
9b9addf3e6 fever items (with_ids, since_id) 2020-10-20 22:52:53 +01:00
Nazar Kanaev
57d2437e9c fever favicons endpoint 2020-10-20 21:57:02 +01:00
Nazar Kanaev
a13aea478e attack of the monster feeds from the future 2020-10-20 21:29:36 +01:00
Nazar Kanaev
6def522f38 http states field for future health check 2020-10-20 20:59:35 +01:00
Nazar Kanaev
6a63d49823 go fmt 2020-10-20 20:54:05 +01:00
Nazar Kanaev
b766cb4ac5 minor settings fix 2020-10-20 20:52:09 +01:00
Nazar Kanaev
32ab1fefa9 change http state storage 2020-10-20 20:48:01 +01:00
Nazar Kanaev
70761c47eb minor ui fix 2020-10-20 19:10:25 +01:00
Nazar Kanaev
6a09d52b85 add missing fever endpoints 2020-10-19 22:05:38 +01:00
Nazar Kanaev
fcaf23d6bc initial fever api 2020-10-19 21:59:14 +01:00
Nazar Kanaev
54c2a6458d fix deleting feeds 2020-10-18 15:40:06 +01:00
Nazar Kanaev
05032ec428 search for favicon right after adding new feed 2020-10-17 22:29:20 +01:00
Nazar Kanaev
e24b905adc ignore all feeds with 4xx and 5xx errors 2020-10-17 13:40:00 +01:00
Nazar Kanaev
f27d0c4cd7 conditional http get 2020-10-17 13:27:12 +01:00
Nazar Kanaev
11a2aa2b4a always run sql init script on start 2020-10-17 12:50:46 +01:00
Nazar Kanaev
2eee8baa26 store http state 2020-10-17 12:47:45 +01:00
Nazar Kanaev
0949ffc027 use minute interval 2020-10-17 12:41:40 +01:00
Nazar Kanaev
286538c5d0 break long words 2020-10-16 14:58:22 +01:00
Nazar Kanaev
78844def40 refresh rate 2020-10-13 22:16:24 +01:00
Nazar Kanaev
e17ce0fb31 refresh rate ui 2020-10-12 20:42:30 +01:00
Yang Bin
55a1c297be fix docker image issues
- use alpine as image base
- add ca-certificates to fix https failed
- define default db file
- expose http port
2020-10-09 22:32:57 +01:00
nkanaev
9bb7ae7902 Update hacking.md 2020-10-06 12:22:01 +01:00
Nazar Kanaev
bcab24ebfa v1.1 2020-10-05 22:27:09 +01:00
Nazar Kanaev
dd058e1637 autogenerate version 2020-10-05 21:59:53 +01:00
nkanaev
63f624251d Update readme.md 2020-10-05 16:16:04 +01:00
Nazar Kanaev
05ee99b4d9 update build.yml 2020-10-05 15:39:19 +01:00
Nazar Kanaev
d5cc9149e3 do not refresh items after feed is deselected 2020-10-03 21:33:35 +01:00
Nazar Kanaev
eb2029132c oopsies 2020-10-03 20:54:51 +01:00
Nazar Kanaev
492e77fd25 done 2020-10-03 13:01:22 +01:00
Nazar Kanaev
b8aa15d554 tablet & mobile layouts 2020-10-03 12:57:30 +01:00
Nazar Kanaev
ee0b440b7b bug fix 2020-10-02 22:14:01 +01:00
Nazar Kanaev
72da9df5ac space 2020-10-02 21:15:55 +01:00
Nazar Kanaev
9872bf84f0 truncate choice text 2020-10-02 20:09:03 +01:00
Nazar Kanaev
6222761dd3 fix favicon fetch bug 2020-10-02 16:49:34 +01:00
Nazar Kanaev
71cc8929ad remove log 2020-10-02 15:18:57 +01:00
Nazar Kanaev
8007853a9a tweak timeouts 2020-10-02 15:17:59 +01:00
Nazar Kanaev
ffc506371c default build 2020-10-02 12:38:33 +01:00
Nazar Kanaev
00fed5e0cf show addr in logs 2020-10-02 12:37:50 +01:00
Nazar Kanaev
6937c349f0 rename assets go file 2020-10-01 21:57:34 +01:00
Nazar Kanaev
bac136603b move icons to platform package 2020-10-01 21:53:48 +01:00
Nazar Kanaev
693b5bcb8d handle server error 2020-09-25 19:23:38 +01:00
Nazar Kanaev
9c7d95f632 tweak 2020-09-25 11:59:08 +01:00
Nazar Kanaev
b8adb3fa2f fluid width 2020-09-25 11:32:15 +01:00
nkanaev
5652b240dc Update hacking.md 2020-09-25 00:05:57 +01:00
nkanaev
c1c0ffab01 Update hacking.md 2020-09-24 23:55:23 +01:00
Nazar Kanaev
349a8980f2 hacking 2020-09-24 23:54:01 +01:00
nkanaev
0bd117804d Update build.yml 2020-09-24 23:28:38 +01:00
331 changed files with 464405 additions and 14351 deletions

View File

@@ -2,42 +2,143 @@ name: build
on:
push:
branches: [master]
tags: ['v*', 'test*']
jobs:
build_macos:
name: Build for MacOS
runs-on: macos-10.15
runs-on: macos-13
steps:
- {name: "Checkout", uses: actions/checkout@v2}
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: "Setup Go"
uses: actions/setup-go@v2
with:
go-version: '^1.17'
- name: Cache Go Modules
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: "Build"
run: |
go version
make build_macos
cd _output/macos && zip -r yarr-macos.zip yarr.app
- {name: "Upload", uses: actions/upload-artifact@v2, with: {name: macos, path: _output/macos/yarr-macos.zip}}
run: make build_macos
- name: Upload
uses: actions/upload-artifact@v2
with:
name: macos
path: _output/macos/yarr.app
build_windows:
name: Build for Windows
runs-on: windows-2019
runs-on: windows-2022
steps:
- {name: "Checkout", uses: actions/checkout@v2}
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: "Setup Go"
uses: actions/setup-go@v2
with:
go-version: '^1.17'
- name: Cache Go Modules
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: "Build"
run: |
go version
make build_windows
- {name: "Upload", uses: actions/upload-artifact@v2, with: {name: windows, path: _output/windows/yarr.exe}}
run: make build_windows
- name: Upload
uses: actions/upload-artifact@v2
with:
name: windows
path: _output/windows/yarr.exe
build_linux:
name: Build for Linux
runs-on: ubuntu-18.04
runs-on: ubuntu-22.04
steps:
- {name: "Checkout", uses: actions/checkout@v2}
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
- {name: "Setup Go", uses: actions/setup-go@v2, with: {go-version: '^1.14'}}
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: "Setup Go"
uses: actions/setup-go@v2
with:
go-version: '^1.17'
- name: Cache Go Modules
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: "Build"
run: make build_linux
- name: Upload
uses: actions/upload-artifact@v2
with:
name: linux
path: _output/linux/yarr
create_release:
name: Create Release
runs-on: ubuntu-latest
if: ${{ !contains(github.ref, 'test') }}
needs: [build_macos, build_windows, build_linux]
steps:
- name: Create Release
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
prerelease: true
- name: Download Artifacts
uses: actions/download-artifact@v2
with:
path: .
- name: Preparation
run: |
go version
make build_linux
cd _output/linux && zip -r yarr-linux.zip yarr
- {name: "Upload", uses: actions/upload-artifact@v2, with: {name: linux, path: _output/linux/yarr-linux.zip}}
ls -R
chmod u+x macos/Contents/MacOS/yarr
chmod u+x linux/yarr
mv macos yarr.app && zip -r yarr-macos.zip yarr.app
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe
mv linux/yarr . && zip yarr-linux.zip yarr
- name: Upload MacOS
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-macos.zip
asset_name: yarr-${{ github.ref }}-macos64.zip
asset_content_type: application/zip
- name: Upload Windows
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-windows.zip
asset_name: yarr-${{ github.ref }}-windows64.zip
asset_content_type: application/zip
- name: Upload Linux
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-linux.zip
asset_name: yarr-${{ github.ref }}-linux64.zip
asset_content_type: application/zip

3
.gitignore vendored
View File

@@ -1,6 +1,5 @@
/server/assets_bundle.go
/gofeed
/_output
/yarr
*.db
*.syso
versioninfo.rc

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

View File

@@ -1,26 +0,0 @@
1 VERSIONINFO
FILEVERSION 1,0,0,0
PRODUCTVERSION 1,0,0,0
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "080904E4"
BEGIN
VALUE "CompanyName", "Old MacDonald's Farm"
VALUE "FileDescription", "Yet another RSS reader"
VALUE "FileVersion", "1.0"
VALUE "InternalName", "yarr"
VALUE "LegalCopyright", "nkanaev"
VALUE "OriginalFilename", "yarr.exe"
VALUE "ProductName", "yarr"
VALUE "ProductVersion", "1.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x809, 1252
END
END
1 ICON "icon.ico"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>

Before

Width:  |  Height:  |  Size: 482 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>

Before

Width:  |  Height:  |  Size: 1011 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>

Before

Width:  |  Height:  |  Size: 448 B

View File

@@ -1,369 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
</head>
<body class="theme-light">
<div class="wrapper d-flex vh-100" id="app" v-cloak>
<!-- feed list -->
<div class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
<drag :width="feedListWidth" @resize="resizeFeedList"></drag>
<div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div>
<button class="toolbar-item"
:class="{active: filterSelected == 'unread'}"
v-b-tooltip.hover.bottom="'Unread'"
@click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == 'starred'}"
v-b-tooltip.hover.bottom="'Starred'"
@click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == ''}"
v-b-tooltip.hover.bottom="'All'"
@click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span>
</button>
<div class="flex-grow-1"></div>
<b-dropdown
right no-caret lazy variant="link"
class="settings-dropdown"
toggle-class="toolbar-item px-2"
ref="menuDropdown">
<template v-slot:button-content class="toolbar-item">
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<b-dropdown-item-button @click="showSettings('create')">
<span class="icon mr-1">{% inline "plus.svg" %}</span>
New Feed
</b-dropdown-item-button>
<b-dropdown-item-button @click.stop="showSettings('manage')">
<span class="icon mr-1">{% inline "list.svg" %}</span>
Manage Feeds
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item-button @click.stop="fetchAllFeeds()">
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Sort by</b-dropdown-header>
<b-dropdown-item-button @click.stop="itemSortNewestFirst=true">
<span class="icon mr-1" :class="{invisible: !itemSortNewestFirst}">{% inline "check.svg" %}</span>
Newest First
</b-dropdown-item-button>
<b-dropdown-item-button @click="itemSortNewestFirst=false">
<span class="icon mr-1" :class="{invisible: itemSortNewestFirst}">{% inline "check.svg" %}</span>
Oldest First
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Subscriptions</b-dropdown-header>
<b-dropdown-form id="opml-import-form" enctype="multipart/form-data">
<input type="file"
id="opml-import"
@change="importOPML"
name="opml"
style="opacity: 0; width: 1px; height: 0; position: absolute;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import">
<span class="icon mr-1">{% inline "download.svg" %}</span>
Import
</label>
</b-dropdown-form>
<b-dropdown-item href="/opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span>
Export
</b-dropdown-item>
</b-dropdown>
</div>
<div class="p-2 overflow-auto border-top flex-grow-1">
<label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2">{% inline "layers.svg" %}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
<span class="counter text-right">{{filteredTotalStats}}</span>
</div>
</label>
<div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1"
:class="{'d-none': filterSelected
&& !filteredFolderStats[folder.id]
&& (!itemSelected || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2"
:class="{expanded: folder.is_expanded}"
@click.prevent="toggleFolderExpanded(folder)">
{% inline "chevron-right.svg" %}
</span>
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
<span class="counter text-right">{{filteredFolderStats[folder.id] || ''}}</span>
</div>
</label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup"
:class="{'d-none': filterSelected
&& !filteredFeedStats[feed.id]
&& (!itemSelected || itemSelectedDetails.feed_id != feed.id)}"
v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span>
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
<span class="counter text-right">{{filteredFeedStats[feed.id] || ''}}</span>
</div>
</label>
</div>
</div>
</div>
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
<span class="icon loading mx-2"></span>
<span class="text-truncate cursor-default noselect">Refreshing ({{loading.feeds}} left)</span>
</div>
</div>
<!-- item list -->
<div class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
<drag :width="itemListWidth" @resize="resizeItemList"></drag>
<div class="px-2 toolbar d-flex align-items-center">
<div class="input-icon flex-grow-1">
<span class="icon">{% inline "search.svg" %}</span>
<input class="d-block toolbar-search" type="" v-model="itemSearch">
</div>
<button class="toolbar-item ml-2"
@click="markItemsRead()"
v-if="filterSelected == 'unread'"
v-b-tooltip.hover.bottom="'Mark All Read'">
<span class="icon">{% inline "check.svg" %}</span>
</button>
</div>
<div class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id"
class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
<div class="selectgroup-label d-flex flex-column">
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<transition name="indicator">
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition>
<small class="flex-fill text-truncate mr-1">
{{feedsById[item.feed_id].title}}
</small>
<small class="flex-shrink-0"><relative-time :val="item.date"/></small>
</div>
<div>{{item.title || 'untitled'}}</div>
</div>
</label>
<button class="btn btn-link btn-block loading my-3" v-if="itemsPage.cur < itemsPage.num"></button>
</div>
</div>
<!-- item show -->
<div class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelected">
<button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)"
v-b-tooltip.hover.bottom="'Mark Starred'">
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
</button>
<button class="toolbar-item"
:disabled="itemSelectedDetails.status=='starred'"
v-b-tooltip.hover.bottom="'Mark Unread'"
@click="toggleItemRead(itemSelectedDetails)">
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
</button>
<a class="toolbar-item" id="content-appearance" v-b-tooltip.hover.bottom="'Appearance'" tabindex="0">
<span class="icon">{% inline "sliders.svg" %}</span>
</a>
<button class="toolbar-item"
:class="{active: itemSelectedReadability}"
@click="getReadable(itemSelectedDetails)"
v-b-tooltip.hover.bottom="'Read Here'">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button>
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" v-b-tooltip.hover.bottom="'Open Link'">
<span class="icon">{% inline "external-link.svg" %}</span>
</a>
<b-popover target="content-appearance" triggers="focus" placement="bottom">
<div class="p-1" style="width: 200px;">
<div class="d-flex">
<label class="themepicker">
<input type="radio" name="settingsTheme" value="light" v-model="theme.name">
<div class="themepicker-label appearance-option"></div>
</label>
<label class="themepicker">
<input type="radio" name="settingsTheme" value="sepia" v-model="theme.name">
<div class="themepicker-label appearance-option"></div>
</label>
<label class="themepicker">
<input type="radio" name="settingsTheme" value="night" v-model="theme.name">
<div class="themepicker-label appearance-option"></div>
</label>
</div>
<div class="mt-2">
<label class="selectgroup">
<input type="radio" name="font" value="" v-model="theme.font">
<div class="selectgroup-label appearance-option">
System Default
</div>
</label>
<label class="selectgroup" v-for="f in fonts" :key="f">
<input type="radio" name="font" :value="f" v-model="theme.font">
<div class="selectgroup-label appearance-option":style="{'font-family': f}">
{{ f }}
</div>
</label>
</div>
<div class="btn-group d-flex mt-2">
<button class="btn btn-outline appearance-option"
style="font-size: 0.8rem" @click="incrFont(-1)">A</button>
<button class="btn btn-outline appearance-option"
style="font-size: 1.2rem" @click="incrFont(1)">A</button>
</div>
</div>
</b-popover>
<div class="flex-grow-1"></div>
<button class="toolbar-item" @click="itemSelected=null" v-b-tooltip.hover.bottom="'Close Article'">
<span class="icon">{% inline "x.svg" %}</span>
</button>
</div>
<div v-if="itemSelected"
ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto"
:style="{'font-family': theme.font, 'font-size': theme.size + 'rem'}">
<h1><b>{{itemSelectedDetails.title}}</b></h1>
<div class="text-muted">
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div>
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
</div>
<hr>
<div v-html="itemSelectedContent"></div>
</div>
</div>
<b-modal id="settings-modal" hide-header hide-footer lazy>
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="$bvModal.hide('settings-modal')">
<span class="icon">{% inline "x.svg" %}</span>
</button>
<div v-if="settings=='create'">
<p class="cursor-default"><b>New Feed</b></p>
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
<label for="feed-url">URL</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0">
<label for="feed-folder" class="mt-3 d-block">
Folder
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
</label>
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
<option value="">---</option>
<option :value="folder.id" v-for="folder in folders">{{ folder.title }}</option>
</select>
<div class="mt-4" v-if="feedNewChoice.length">
<p class="mb-2">
Multiple feeds found. Choose one below:
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
</p>
<label class="selectgroup" v-for="choice in feedNewChoice">
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
<div class="selectgroup-label">
<div>{{ choice.title }}</div>
<div :class="{light: choice.title}">{{ choice.url }}</div>
</div>
</label>
</div>
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
</form>
</div>
<div v-else-if="settings=='manage'">
<p class="cursor-default"><b>Manage Feeds</b></p>
<div v-for="folder in foldersWithFeeds" class="mt-4" :key="folder.id">
<div class="list-row d-flex align-items-center">
<div class="w-100 text-truncate" v-if="folder.id">
<span class="icon mr-2">{% inline "folder.svg" %}</span>
{{ folder.title }}
</div>
<div class="flex-shrink-0" v-if="folder.id">
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
<template v-slot:button-content>
<span class="icon">{% inline "more-vertical.svg" %}</span>
</template>
<b-dropdown-header>{{folder.title}}</b-dropdown-header>
<b-dropdown-item @click.prevent="renameFolder(folder)">Rename</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="dropdown-danger"
@click.prevent="deleteFolder(folder)">
Delete
</b-dropdown-item>
</b-dropdown>
</div>
</div>
<div v-for="feed in folder.feeds" class="list-row d-flex align-items-center" :key="feed.id">
<div class="w-100 text-truncate">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span>
{{ feed.title }}
</div>
<div class="flex-shrink-0">
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
<template v-slot:button-content>
<span class="icon">{% inline "more-vertical.svg" %}</span>
</template>
<b-dropdown-header>{{feed.title}}</b-dropdown-header>
<b-dropdown-item :href="feed.link" target="_blank" v-if="feed.link">Visit Website</b-dropdown-item>
<b-dropdown-divider v-if="feed.link"></b-dropdown-divider>
<b-dropdown-item @click.prevent="renameFeed(feed)">Rename</b-dropdown-item>
<b-dropdown-divider v-if="folders.length"></b-dropdown-divider>
<b-dropdown-header v-if="folders.length">Move to...</b-dropdown-header>
<b-dropdown-item @click="moveFeed(feed, null)" v-if="feed.folder_id">
---
</b-dropdown-item>
<b-dropdown-item-button
v-if="folder.id != feed.folder_id"
v-for="folder in folders"
@click="moveFeed(feed, folder)">
<span class="icon mr-1">{% inline "folder.svg" %}</span>
{{ folder.title }}
</b-dropdown-item-button>
<b-dropdown-item-button @click="moveFeedToNewFolder(feed)">
<span class="text-muted icon mr-1">{% inline "plus.svg" %}</span>
<span class="text-muted">New Folder</span>
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="dropdown-danger"
@click.prevent="deleteFeed(feed)">
Delete
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- polyfill -->
<script src="./static/javascripts/fetch.umd.js"></script>
<script src="./static/javascripts/url-polyfill.min.js"></script>
<!-- external -->
<script src="./static/javascripts/vue.min.js"></script>
<script src="./static/javascripts/vue-lazyload.js"></script>
<script src="./static/javascripts/popper.min.js"></script>
<script src="./static/javascripts/bootstrap-vue.min.js"></script>
<script src="./static/javascripts/Readability.js"></script>
<script src="./static/javascripts/purify.min.js"></script>
<!-- internal -->
<script src="./static/javascripts/api.js"></script>
<script src="./static/javascripts/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,95 +0,0 @@
"use strict";
(function() {
var api = function(method, endpoint, data) {
return fetch(endpoint, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
})
}
var json = function(res) {
return res.json()
}
var param = function(query) {
if (!query) return ''
return '?' + Object.keys(query).map(function(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(query[key])
}).join('&')
}
window.api = {
feeds: {
list: function() {
return api('get', '/api/feeds').then(json)
},
create: function(data) {
return api('post', '/api/feeds', data).then(json)
},
update: function(id, data) {
return api('put', '/api/feeds/' + id, data)
},
delete: function(id) {
return api('delete', '/api/feeds/' + id)
},
list_items: function(id) {
return api('get', '/api/feeds/' + id + '/items').then(json)
},
refresh: function() {
return api('post', '/api/feeds/refresh')
},
},
folders: {
list: function() {
return api('get', '/api/folders').then(json)
},
create: function(data) {
return api('post', '/api/folders', data).then(json)
},
update: function(id, data) {
return api('put', '/api/folders/' + id, data)
},
delete: function(id) {
return api('delete', '/api/folders/' + id)
},
list_items: function(id) {
return api('get', '/api/folders/' + id + '/items').then(json)
}
},
items: {
list: function(query) {
return api('get', '/api/items' + param(query)).then(json)
},
update: function(id, data) {
return api('put', '/api/items/' + id, data)
},
mark_read: function(query) {
return api('put', '/api/items' + param(query))
},
},
settings: {
get: function() {
return api('get', '/api/settings').then(json)
},
update: function(data) {
return api('put', '/api/settings', data)
},
},
status: function() {
return api('get', '/api/status').then(json)
},
upload_opml: function(form) {
return fetch('/opml/import', {
method: 'post',
body: new FormData(form),
})
},
crawl: function(url) {
return fetch('/page?url=' + url).then(function(res) {
return res.text()
})
}
}
})()

File diff suppressed because one or more lines are too long

View File

@@ -1,609 +0,0 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(factory((global.WHATWGFetch = {})));
}(this, (function (exports) { 'use strict';
var global = (typeof self !== 'undefined' && self) || (typeof global !== 'undefined' && global);
var support = {
searchParams: 'URLSearchParams' in global,
iterable: 'Symbol' in global && 'iterator' in Symbol,
blob:
'FileReader' in global &&
'Blob' in global &&
(function() {
try {
new Blob();
return true
} catch (e) {
return false
}
})(),
formData: 'FormData' in global,
arrayBuffer: 'ArrayBuffer' in global
};
function isDataView(obj) {
return obj && DataView.prototype.isPrototypeOf(obj)
}
if (support.arrayBuffer) {
var viewClasses = [
'[object Int8Array]',
'[object Uint8Array]',
'[object Uint8ClampedArray]',
'[object Int16Array]',
'[object Uint16Array]',
'[object Int32Array]',
'[object Uint32Array]',
'[object Float32Array]',
'[object Float64Array]'
];
var isArrayBufferView =
ArrayBuffer.isView ||
function(obj) {
return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1
};
}
function normalizeName(name) {
if (typeof name !== 'string') {
name = String(name);
}
if (/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name) || name === '') {
throw new TypeError('Invalid character in header field name')
}
return name.toLowerCase()
}
function normalizeValue(value) {
if (typeof value !== 'string') {
value = String(value);
}
return value
}
// Build a destructive iterator for the value list
function iteratorFor(items) {
var iterator = {
next: function() {
var value = items.shift();
return {done: value === undefined, value: value}
}
};
if (support.iterable) {
iterator[Symbol.iterator] = function() {
return iterator
};
}
return iterator
}
function Headers(headers) {
this.map = {};
if (headers instanceof Headers) {
headers.forEach(function(value, name) {
this.append(name, value);
}, this);
} else if (Array.isArray(headers)) {
headers.forEach(function(header) {
this.append(header[0], header[1]);
}, this);
} else if (headers) {
Object.getOwnPropertyNames(headers).forEach(function(name) {
this.append(name, headers[name]);
}, this);
}
}
Headers.prototype.append = function(name, value) {
name = normalizeName(name);
value = normalizeValue(value);
var oldValue = this.map[name];
this.map[name] = oldValue ? oldValue + ', ' + value : value;
};
Headers.prototype['delete'] = function(name) {
delete this.map[normalizeName(name)];
};
Headers.prototype.get = function(name) {
name = normalizeName(name);
return this.has(name) ? this.map[name] : null
};
Headers.prototype.has = function(name) {
return this.map.hasOwnProperty(normalizeName(name))
};
Headers.prototype.set = function(name, value) {
this.map[normalizeName(name)] = normalizeValue(value);
};
Headers.prototype.forEach = function(callback, thisArg) {
for (var name in this.map) {
if (this.map.hasOwnProperty(name)) {
callback.call(thisArg, this.map[name], name, this);
}
}
};
Headers.prototype.keys = function() {
var items = [];
this.forEach(function(value, name) {
items.push(name);
});
return iteratorFor(items)
};
Headers.prototype.values = function() {
var items = [];
this.forEach(function(value) {
items.push(value);
});
return iteratorFor(items)
};
Headers.prototype.entries = function() {
var items = [];
this.forEach(function(value, name) {
items.push([name, value]);
});
return iteratorFor(items)
};
if (support.iterable) {
Headers.prototype[Symbol.iterator] = Headers.prototype.entries;
}
function consumed(body) {
if (body.bodyUsed) {
return Promise.reject(new TypeError('Already read'))
}
body.bodyUsed = true;
}
function fileReaderReady(reader) {
return new Promise(function(resolve, reject) {
reader.onload = function() {
resolve(reader.result);
};
reader.onerror = function() {
reject(reader.error);
};
})
}
function readBlobAsArrayBuffer(blob) {
var reader = new FileReader();
var promise = fileReaderReady(reader);
reader.readAsArrayBuffer(blob);
return promise
}
function readBlobAsText(blob) {
var reader = new FileReader();
var promise = fileReaderReady(reader);
reader.readAsText(blob);
return promise
}
function readArrayBufferAsText(buf) {
var view = new Uint8Array(buf);
var chars = new Array(view.length);
for (var i = 0; i < view.length; i++) {
chars[i] = String.fromCharCode(view[i]);
}
return chars.join('')
}
function bufferClone(buf) {
if (buf.slice) {
return buf.slice(0)
} else {
var view = new Uint8Array(buf.byteLength);
view.set(new Uint8Array(buf));
return view.buffer
}
}
function Body() {
this.bodyUsed = false;
this._initBody = function(body) {
/*
fetch-mock wraps the Response object in an ES6 Proxy to
provide useful test harness features such as flush. However, on
ES5 browsers without fetch or Proxy support pollyfills must be used;
the proxy-pollyfill is unable to proxy an attribute unless it exists
on the object before the Proxy is created. This change ensures
Response.bodyUsed exists on the instance, while maintaining the
semantic of setting Request.bodyUsed in the constructor before
_initBody is called.
*/
this.bodyUsed = this.bodyUsed;
this._bodyInit = body;
if (!body) {
this._bodyText = '';
} else if (typeof body === 'string') {
this._bodyText = body;
} else if (support.blob && Blob.prototype.isPrototypeOf(body)) {
this._bodyBlob = body;
} else if (support.formData && FormData.prototype.isPrototypeOf(body)) {
this._bodyFormData = body;
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this._bodyText = body.toString();
} else if (support.arrayBuffer && support.blob && isDataView(body)) {
this._bodyArrayBuffer = bufferClone(body.buffer);
// IE 10-11 can't handle a DataView body.
this._bodyInit = new Blob([this._bodyArrayBuffer]);
} else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) {
this._bodyArrayBuffer = bufferClone(body);
} else {
this._bodyText = body = Object.prototype.toString.call(body);
}
if (!this.headers.get('content-type')) {
if (typeof body === 'string') {
this.headers.set('content-type', 'text/plain;charset=UTF-8');
} else if (this._bodyBlob && this._bodyBlob.type) {
this.headers.set('content-type', this._bodyBlob.type);
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
}
}
};
if (support.blob) {
this.blob = function() {
var rejected = consumed(this);
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return Promise.resolve(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(new Blob([this._bodyArrayBuffer]))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as blob')
} else {
return Promise.resolve(new Blob([this._bodyText]))
}
};
this.arrayBuffer = function() {
if (this._bodyArrayBuffer) {
var isConsumed = consumed(this);
if (isConsumed) {
return isConsumed
}
if (ArrayBuffer.isView(this._bodyArrayBuffer)) {
return Promise.resolve(
this._bodyArrayBuffer.buffer.slice(
this._bodyArrayBuffer.byteOffset,
this._bodyArrayBuffer.byteOffset + this._bodyArrayBuffer.byteLength
)
)
} else {
return Promise.resolve(this._bodyArrayBuffer)
}
} else {
return this.blob().then(readBlobAsArrayBuffer)
}
};
}
this.text = function() {
var rejected = consumed(this);
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return readBlobAsText(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as text')
} else {
return Promise.resolve(this._bodyText)
}
};
if (support.formData) {
this.formData = function() {
return this.text().then(decode)
};
}
this.json = function() {
return this.text().then(JSON.parse)
};
return this
}
// HTTP methods whose capitalization should be normalized
var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'];
function normalizeMethod(method) {
var upcased = method.toUpperCase();
return methods.indexOf(upcased) > -1 ? upcased : method
}
function Request(input, options) {
if (!(this instanceof Request)) {
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
}
options = options || {};
var body = options.body;
if (input instanceof Request) {
if (input.bodyUsed) {
throw new TypeError('Already read')
}
this.url = input.url;
this.credentials = input.credentials;
if (!options.headers) {
this.headers = new Headers(input.headers);
}
this.method = input.method;
this.mode = input.mode;
this.signal = input.signal;
if (!body && input._bodyInit != null) {
body = input._bodyInit;
input.bodyUsed = true;
}
} else {
this.url = String(input);
}
this.credentials = options.credentials || this.credentials || 'same-origin';
if (options.headers || !this.headers) {
this.headers = new Headers(options.headers);
}
this.method = normalizeMethod(options.method || this.method || 'GET');
this.mode = options.mode || this.mode || null;
this.signal = options.signal || this.signal;
this.referrer = null;
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
throw new TypeError('Body not allowed for GET or HEAD requests')
}
this._initBody(body);
if (this.method === 'GET' || this.method === 'HEAD') {
if (options.cache === 'no-store' || options.cache === 'no-cache') {
// Search for a '_' parameter in the query string
var reParamSearch = /([?&])_=[^&]*/;
if (reParamSearch.test(this.url)) {
// If it already exists then set the value with the current time
this.url = this.url.replace(reParamSearch, '$1_=' + new Date().getTime());
} else {
// Otherwise add a new '_' parameter to the end with the current time
var reQueryString = /\?/;
this.url += (reQueryString.test(this.url) ? '&' : '?') + '_=' + new Date().getTime();
}
}
}
}
Request.prototype.clone = function() {
return new Request(this, {body: this._bodyInit})
};
function decode(body) {
var form = new FormData();
body
.trim()
.split('&')
.forEach(function(bytes) {
if (bytes) {
var split = bytes.split('=');
var name = split.shift().replace(/\+/g, ' ');
var value = split.join('=').replace(/\+/g, ' ');
form.append(decodeURIComponent(name), decodeURIComponent(value));
}
});
return form
}
function parseHeaders(rawHeaders) {
var headers = new Headers();
// Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
// https://tools.ietf.org/html/rfc7230#section-3.2
var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ');
preProcessedHeaders.split(/\r?\n/).forEach(function(line) {
var parts = line.split(':');
var key = parts.shift().trim();
if (key) {
var value = parts.join(':').trim();
headers.append(key, value);
}
});
return headers
}
Body.call(Request.prototype);
function Response(bodyInit, options) {
if (!(this instanceof Response)) {
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
}
if (!options) {
options = {};
}
this.type = 'default';
this.status = options.status === undefined ? 200 : options.status;
this.ok = this.status >= 200 && this.status < 300;
this.statusText = 'statusText' in options ? options.statusText : '';
this.headers = new Headers(options.headers);
this.url = options.url || '';
this._initBody(bodyInit);
}
Body.call(Response.prototype);
Response.prototype.clone = function() {
return new Response(this._bodyInit, {
status: this.status,
statusText: this.statusText,
headers: new Headers(this.headers),
url: this.url
})
};
Response.error = function() {
var response = new Response(null, {status: 0, statusText: ''});
response.type = 'error';
return response
};
var redirectStatuses = [301, 302, 303, 307, 308];
Response.redirect = function(url, status) {
if (redirectStatuses.indexOf(status) === -1) {
throw new RangeError('Invalid status code')
}
return new Response(null, {status: status, headers: {location: url}})
};
exports.DOMException = global.DOMException;
try {
new exports.DOMException();
} catch (err) {
exports.DOMException = function(message, name) {
this.message = message;
this.name = name;
var error = Error(message);
this.stack = error.stack;
};
exports.DOMException.prototype = Object.create(Error.prototype);
exports.DOMException.prototype.constructor = exports.DOMException;
}
function fetch(input, init) {
return new Promise(function(resolve, reject) {
var request = new Request(input, init);
if (request.signal && request.signal.aborted) {
return reject(new exports.DOMException('Aborted', 'AbortError'))
}
var xhr = new XMLHttpRequest();
function abortXhr() {
xhr.abort();
}
xhr.onload = function() {
var options = {
status: xhr.status,
statusText: xhr.statusText,
headers: parseHeaders(xhr.getAllResponseHeaders() || '')
};
options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL');
var body = 'response' in xhr ? xhr.response : xhr.responseText;
setTimeout(function() {
resolve(new Response(body, options));
}, 0);
};
xhr.onerror = function() {
setTimeout(function() {
reject(new TypeError('Network request failed'));
}, 0);
};
xhr.ontimeout = function() {
setTimeout(function() {
reject(new TypeError('Network request failed'));
}, 0);
};
xhr.onabort = function() {
setTimeout(function() {
reject(new exports.DOMException('Aborted', 'AbortError'));
}, 0);
};
function fixUrl(url) {
try {
return url === '' && global.location.href ? global.location.href : url
} catch (e) {
return url
}
}
xhr.open(request.method, fixUrl(request.url), true);
if (request.credentials === 'include') {
xhr.withCredentials = true;
} else if (request.credentials === 'omit') {
xhr.withCredentials = false;
}
if ('responseType' in xhr) {
if (support.blob) {
xhr.responseType = 'blob';
} else if (
support.arrayBuffer &&
request.headers.get('Content-Type') &&
request.headers.get('Content-Type').indexOf('application/octet-stream') !== -1
) {
xhr.responseType = 'arraybuffer';
}
}
if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers)) {
Object.getOwnPropertyNames(init.headers).forEach(function(name) {
xhr.setRequestHeader(name, normalizeValue(init.headers[name]));
});
} else {
request.headers.forEach(function(value, name) {
xhr.setRequestHeader(name, value);
});
}
if (request.signal) {
request.signal.addEventListener('abort', abortXhr);
xhr.onreadystatechange = function() {
// DONE (success or failure)
if (xhr.readyState === 4) {
request.signal.removeEventListener('abort', abortXhr);
}
};
}
xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit);
})
}
fetch.polyfill = true;
if (!global.fetch) {
global.fetch = fetch;
global.Headers = Headers;
global.Request = Request;
global.Response = Response;
}
exports.Headers = Headers;
exports.Request = Request;
exports.Response = Response;
exports.fetch = fetch;
Object.defineProperty(exports, '__esModule', { value: true });
})));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

45
bin/feedtest.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/nkanaev/yarr/src/parser"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: <script> [url|filepath]")
return
}
url := os.Args[1]
var r io.Reader
if strings.HasPrefix(url, "http") {
res, err := http.Get(url)
if err != nil {
log.Fatalf("failed to get url %s: %s", url, err)
}
r = res.Body
} else {
var err error
r, err = os.Open(url)
if err != nil {
log.Fatalf("failed to open file: %s", err)
}
}
feed, err := parser.Parse(r)
if err != nil {
log.Fatalf("failed to parse feed: %s", err)
}
body, err := json.MarshalIndent(feed, "", " ")
if err != nil {
log.Fatalf("failed to marshall feed: %s", err)
}
fmt.Println(string(body))
}

View File

@@ -0,0 +1,48 @@
package main
import (
"flag"
"io/ioutil"
"strings"
)
var rsrc = `1 VERSIONINFO
FILEVERSION {VERSION_COMMA},0,0
PRODUCTVERSION {VERSION_COMMA},0,0
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "080904E4"
BEGIN
VALUE "CompanyName", "Old MacDonald's Farm"
VALUE "FileDescription", "Yet another RSS reader"
VALUE "FileVersion", "{VERSION}"
VALUE "InternalName", "yarr"
VALUE "LegalCopyright", "nkanaev"
VALUE "OriginalFilename", "yarr.exe"
VALUE "ProductName", "yarr"
VALUE "ProductVersion", "{VERSION}"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x809, 1252
END
END
1 ICON "icon.ico"
`
func main() {
var version, outfile string
flag.StringVar(&version, "version", "0.0", "")
flag.StringVar(&outfile, "outfile", "versioninfo.rc", "")
flag.Parse()
version_comma := strings.ReplaceAll(version, ".", ",")
out := strings.ReplaceAll(rsrc, "{VERSION}", version)
out = strings.ReplaceAll(out, "{VERSION_COMMA}", version_comma)
ioutil.WriteFile(outfile, []byte(out), 0644)
}

View File

@@ -1,13 +1,15 @@
package main
import (
"os"
"path"
"flag"
"fmt"
"io/ioutil"
"os/exec"
"strconv"
"log"
"os"
"os/exec"
"path"
"strconv"
"strings"
)
var plist = `<?xml version="1.0" encoding="UTF-8"?>
@@ -21,7 +23,7 @@ var plist = `<?xml version="1.0" encoding="UTF-8"?>
<key>CFBundleIdentifier</key>
<string>nkanaev.yarr</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<string>VERSION</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
@@ -35,11 +37,6 @@ var plist = `<?xml version="1.0" encoding="UTF-8"?>
<key>NSHighResolutionCapable</key>
<string>True</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
<key>LSUIElement</key>
@@ -59,7 +56,11 @@ func run(cmd ...string) {
}
func main() {
outdir := os.Args[1]
var version, outdir string
flag.StringVar(&version, "version", "0.0", "")
flag.StringVar(&outdir, "outdir", "", "")
flag.Parse()
outfile := "yarr"
binDir := path.Join(outdir, "yarr.app", "Contents/MacOS")
@@ -74,7 +75,7 @@ func main() {
f, _ := ioutil.ReadFile(path.Join(outdir, outfile))
ioutil.WriteFile(path.Join(binDir, outfile), f, 0755)
ioutil.WriteFile(plistFile, []byte(plist), 0644)
ioutil.WriteFile(plistFile, []byte(strings.Replace(plist, "VERSION", version, 1)), 0644)
ioutil.WriteFile(pkginfoFile, []byte("APPL????"), 0644)
iconFile := path.Join(outdir, "icon.png")
@@ -84,9 +85,9 @@ func main() {
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
if res == 1024 || res == 64 {
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res / 2, res / 2)
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res/2, res/2)
}
cmd := []string {
cmd := []string{
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
iconFile, "--out", path.Join(iconsetDir, outfile),
}

41
bin/reader.go Normal file
View File

@@ -0,0 +1,41 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/nkanaev/yarr/src/content/readability"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: <script> [url]")
return
}
url := os.Args[1]
var r io.Reader
if strings.HasPrefix(url, "http") {
res, err := http.Get(url)
if err != nil {
log.Fatalf("failed to get url %s: %s", url, err)
}
r = res.Body
} else {
var err error
r, err = os.Open(url)
if err != nil {
log.Fatalf("failed to open file: %s", err)
}
}
content, err := readability.ExtractContent(r)
if err != nil {
log.Fatalf("failed to extract content: %s", err)
}
fmt.Println(content)
}

44
build.md Normal file
View File

@@ -0,0 +1,44 @@
## Compilation
Install `Go >= 1.17` and `GCC`. Get the source code:
git clone https://github.com/nkanaev/yarr.git
Then run one of the corresponding commands:
# create an executable for the host os
make build_macos # -> _output/macos/yarr.app
make build_linux # -> _output/linux/yarr
make build_windows # -> _output/windows/yarr.exe
# host-specific cli version (no gui)
make build_default # -> _output/yarr
# ... or start a dev server locally
make serve # starts a server at http://localhost:7070
# ... or build a docker image
docker build -t yarr .
## ARM compilation
The instructions below are to cross-compile *yarr* to `Linux/ARM*`.
Build:
docker build -t yarr.arm -f dockerfile.arm .
Test:
# inside host
docker run -it --rm yarr.arm
# then, inside container
cd /root/out
qemu-aarch64 -L /usr/aarch64-linux-gnu/ yarr.arm64
Extract files from images:
CID=$(docker create yarr.arm)
docker cp -a "$CID:/root/out" .
docker rm "$CID"

93
doc/changelog.txt Normal file
View File

@@ -0,0 +1,93 @@
# v2.4 (2023-08-15)
- (new) ARM build support (thanks to @tillcash & @fenuks)
- (new) auth configuration via param or env variable (thanks to @pierreprinetti)
- (new) web app manifest for an app-like experience on mobile (thanks to @qbit)
- (fix) concurrency issue crashing the app (thanks to @quoing)
- (fix) favicon visibility in dark mode (thanks to @caycaycarly for the report)
- (fix) autoloading more articles not working in certain edge cases (thanks to @fenuks for the report)
- (fix) handle Google URL redirects in "Read Here" (thanks to @cubbei for discovery)
- (fix) handle failures to extract content in "Read Here" (thanks to @grigio for the report)
- (fix) article view width for high resolution screens (thanks to @whaler-ragweed for the report)
- (fix) make newly added feed searchable (thanks to @BMorearty for the report)
- (fix) feed/article selection accessibility via arrow keys (thanks to @grigio and @tillcash)
- (fix) keyboard shortcuts in Firefox (thanks to @kaloyan13)
- (fix) keyboard shortcuts in non-English layouts (thanks to @kaloyan13)
- (fix) sorting articles with timezone information (thanks to @x2cf)
- (fix) handling links set in guid only for certain feeds (thanks to @adaszko for the report)
- (fix) crashes caused by feed icon endpoint (thanks to @adaszko)
# v2.3 (2022-05-03)
- (fix) handling encodings (thanks to @f100024 & @fserb)
- (fix) parsing xml feeds with illegal characters (thanks to @stepelu for the report)
- (fix) old articles reappearing as unread (thanks to @adaszko for the report)
- (fix) item list scrolling issue on large screens (thanks to @bielej for the report)
- (fix) keyboard shortcuts color in dark mode (thanks to @John09f9 for the report)
- (etc) autofocus when adding a new feed (thanks to @lakuapik)
# v2.2 (2021-11-20)
- (fix) windows console support (thanks to @dufferzafar for the report)
- (fix) remove html tags from article titles (thanks to Alex Went for the report)
- (etc) autoselect current folder when adding a new feed (thanks to @krkk)
- (etc) folder/feed settings menu available across all filters
# v2.1 (2021-08-16)
- (new) configuration via env variables
- (fix) missing `content-type` headers (thanks to @verahawk for the report)
- (fix) handle opml files not following the spec (thanks to @huangnauh for the report)
- (fix) pagination in unread/starred feeds (thanks to @Farow for the report)
- (fix) handling feeds with non-utf8 encodings (thanks to @fserb for the report)
- (fix) errors caused by empty feeds (thanks to @decke)
- (fix) recognize all audio mime types as podcasts (thanks to @krkk)
- (fix) ui tweaks (thanks to @Farow)
# v2.0 (2021-04-18)
- (new) user interface tweaks
- (new) feed parser fully rewritten
- (new) show youtube/vimeo iframes in "read here"
- (new) keyboard shortcuts for article scrolling & toggling "read here"
- (new) more options for auto-refresh intervals
- (fix) `-base` not serving static files (thanks to @vfaronov)
- (etc) 3rd-party dependencies reduced to the bare minimum
special thanks to @tillcash for feedback & suggestions.
# v1.4 (2021-03-11)
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
- (new) show podcast audio
- (fix) deleting feeds
- (etc) minor ui tweaks & changes
# v1.3 (2021-02-18)
- (fix) log out functionality if authentication is set
- (fix) import opml if authentication is set
- (fix) login page if authentication is set (thanks to @einschmidt)
# v1.2 (2021-02-11)
- (new) autorefresh rate
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
- (new) show feed errors in feed management modal
- (new) `-open` flag for automatically opening the server url
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
- (new) `-auth-file` flag for authentication
- (new) `-cert-file` & `-key-file` flags for TLS
- (fix) wrapping long words in the ui to prevent vertical scroll
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
# v1.1 (2020-10-05)
- (new) responsive design
- (fix) server crash on favicon fetch timeout (reported by @minioin)
- (fix) handling byte order marks in feeds (reported by @ilaer)
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
# v1.0 (2020-09-24)
Initial Release

171
doc/formats.txt Normal file
View File

@@ -0,0 +1,171 @@
# model
- feed:
- title
rdf>channel>title (rss 0.90)
rdf>channel>title (rss 1.0)
rss>channel>title (rss 0.91 netscape)
rss>channel>title (rss 0.91 userland)
rss>channel>title (rss 2.0)
feed>title (atom 1.0)
- site_url
rdf>channel>link (rss 0.90)
rdf>channel>link (rss 1.0)
rss>channel>link (rss 0.91 netscape)
rss>channel>link (rss 0.91 userland)
rss>channel>link (rss 2.0)
feed>link (atom 1.0)
- item:
- guid
rss>channel>guid (rss 2.0)
feed>entry>id (atom 1.0)
- date
rdf>item>dc:date (rss 1.0)
rss>channel>pubDate (rss 2.0)
feed>entry>updated (atom 1.0)
feed>entry>published (atom 1.0)
- url
rdf>item>link (rss 0.90)
rdf>item>link (rss 1.0)
rss>channel>item>link (rss 0.91 netscape)
rss>channel>item>link (rss 0.91 userland)
rss>channel>item>link (rss 2.0)
feed>entry>link[rel=alternate] (atom 1.0)
- title
rdf>item>title (rss 0.90)
rdf>item>title (rss 1.0)
rss>channel>item>title (rss 0.91 netscape)
rss>channel>item>title (rss 0.91 userland)
rss>channel>item>title (rss 2.0)
feed>entry>title (atom 1.0)
- content
rss>channel>item>description (rss 0.91 netscape)
rss>channel>item>description (rss 0.91 userland)
rss>channel>item>description (rss 2.0)
rdf>item>description (rss 1.0)
rdf>item>content:encoded (rss 1.0)
feed>entry>content (atom 1.0)
- image_url
rss>item>media:thumbnail:url (rss 2.0 media)
feed>entry>enclosure[rel='image/*'] (atom 1.0) ???
- audio_url
rss>item>enclosure:url (audio/*) (rss 2.0)
feed>entry>enclosure (audio/*') (atom 1.0) ???
# specs
- rss
https://en.wikipedia.org/wiki/RSS
- 0.90:
https://www.rssboard.org/rss-0-9-0
https://web.archive.org/web/20001208063100/http://my.netscape.com/publish/help/quickstart.html
- 0.91 (netscape)
https://www.rssboard.org/rss-0-9-1-netscape
- 0.91 (userland)
https://www.rssboard.org/rss-0-9-1
- 0.92
https://www.rssboard.org/rss-0-9-2
by userland, no significant changes from 0.91
- 0.93 (withdrawn)
http://backend.userland.com/rss093
- 0.94 (withdrawn)
- 1.0
https://web.resource.org/rss/1.0/
https://web.archive.org/web/20021014094554/https://web.resource.org/rss/1.0/spec
reintroduced rdf from 0.90, added dublincore namespaces etc
namespaces:
content: http://purl.org/rss/1.0/modules/content/
dc: http://purl.org/dc/elements/1.1/
- 2.0
https://cyber.harvard.edu/rss/rss.html
https://www.rssboard.org/rss-2-0
- atom
https://en.wikipedia.org/wiki/Atom_(Web_standard)
- 0.3
https://support.google.com/merchants/answer/160598?hl=en
http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
- 1.0
https://tools.ietf.org/html/rfc4287
https://validator.w3.org/feed/docs/atom.html
- json
https://en.wikipedia.org/wiki/JSON_Feed
- 1.0
https://jsonfeed.org/version/1
- 1.1
https://jsonfeed.org/version/1.1
- media
https://www.rssboard.org/media-rss
xml namespace for:
- rss 2.0
- atom 1.0
# extensions
- feedburner
https://en.wikipedia.org/wiki/FeedBurner
- media
https://www.rssboard.org/media-rss
initially for rss 2.0, used in atom 1.0 as well (youtube)
- itunes podcasts
https://help.apple.com/itc/podcasts_connect/#/itcb54353390
https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
- google podcasts
https://support.google.com/podcast-publishers/answer/9889544?visit_id=637523492443301715-1225759684&rd=1
# parsers
https://github.com/kurtmckee/feedparser
https://github.com/mmcdole/gofeed
https://github.com/miniflux/v2/tree/2.0.28/reader/
https://github.com/Ranchero-Software/RSParser
https://github.com/feederco/feeder-parser
https://github.com/mmcdole/gofeed/commit/9665eb31016cef3d15ab85574bc6fdbe890cd252
# platforms
A list of centralized content providers worth keeping track of.
The parser should be reasonably handle content provided by them.
Delete any from the list in case they drop support of web feeds.
- blogger
- cnblogs
- flickr
- hatenablog
- livejournal
- medium
- posthaven
- reddit
- substack
- tumblr
- vimeo
- wordpress
- youtube
# links
https://indieweb.org/feed#Criticism
https://inessential.com/2013/03/18/brians_stupid_feed_tricks

70
doc/platform.txt Normal file
View File

@@ -0,0 +1,70 @@
Incomplete & inaccurate platform-specific notes.
# MacOS Icon
The format for desktop apps is [.icns][icns].
AFAIK, the format is not open (even though it had been [reverse-engineered][icns-re]),
and I couldn't find any 3rd party tool that'd fully support it.
The easiest way for creating icon file is either via `Xcode`,
or by using built-in `iconutil` command that ships with MacOS.
The steps are provided below:
$ sips -s format png --resampleWidth 1024 source.png --out /path/to/icons/icon_512x512@2x.png
$ sips -s format png --resampleWidth 512 source.png --out /path/to/icons/icon_512x512.png
$ sips -s format png --resampleWidth 256 source.png --out /path/to/icons/icon_256x256.png
$ sips -s format png --resampleWidth 128 source.png --out /path/to/icons/icon_128x128.png
$ sips -s format png --resampleWidth 64 source.png --out /path/to/icons/icon_32x32@2x.png
$ sips -s format png --resampleWidth 32 source.png --out /path/to/icons/icon_32x32.png
$ sips -s format png --resampleWidth 16 source.png --out /path/to/icons/icon_16x16.png
$ iconutil -c icns /path/to/icons -o icon.icns
[icns]: https://en.wikipedia.org/wiki/Apple_Icon_Image_format
[icns-re]: https://www.macdisk.com/maciconen.php#RLE
# Windows Icon
Terminology:
- coff: precursor to pe format (portable executable). pe is an extension of coff.
- manifest: xml file with platform requirements needed during runtime
- https://docs.microsoft.com/en-us/windows/win32/sbscs/application-manifests
- https://www.samlogic.net/articles/manifest.htm
- rc: dsl file that describes the application metadata & resources
- https://docs.microsoft.com/en-gb/windows/win32/menurc/about-resource-files
- https://github.com/josephspurrier/goversioninfo/blob/master/testdata/rc/versioninfo.rc (sample rc)
Windows Icons are directly embedded to the binary.
To do so one needs to provide `.syso` file prior to compiling Go code,
which will be passed to the linker. So, basically `.syso` is any
[object file][obj-file] that the linker understands.
More info here: [ticket][syso-ticket] & [commit][syso-commit].
Note to self: running `go build main.go` [won't embed][syso-quirk]
.syso file if it isn't located in a package directory.
Tools to create `.syso` files:
- [windres][windres]: ships with mingw (gnu tools for windows)
- [rsrc][rsrc]: written in Go, wasn't considered at the time
due to the critical bug with icon alignment
- [goversioninfo][goversioninfo]: rsrc wrapper
with manifest file creation via json
[obj-file]: https://en.wikipedia.org/wiki/Object_file
[syso-linker]: https://github.com/golang/go/issues/23278#issuecomment-354567634
[syso-ticket]: https://github.com/golang/go/issues/1552
[syso-commit]: https://github.com/golang/go/commit/b0996334
[syso-quirk]: https://github.com/golang/go/issues/16090
[mingw]: https://en.wikipedia.org/wiki/MinGW
[coff]: https://en.wikipedia.org/wiki/COFF
[windres]: https://sourceware.org/binutils/docs/binutils/windres.html
[rsrs]: https://github.com/akavel/rsrc
[rsrc-bug]: https://github.com/akavel/rsrc/issues/12
[goversioninfo]: github.com/josephspurrier/goversioninfo
[winicon-guide]: https://docs.microsoft.com/en-us/windows/win32/uxguide/vis-icons#size-requirements
[res-vs-coff]: http://www.mingw.org/wiki/MS_resource_compiler
[versioninfo-resource]: https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource

62
doc/rationale.txt Normal file
View File

@@ -0,0 +1,62 @@
# goal
The goal is to ship a (subjectively ergonomic) software for reading feeds.
*yarr* is not designed/intended to be used as an archiving tool.
The initial goal was to serve the author's needs:
a desktop application accessible via web browser
(because keeping 2 apps running, feed reader & browser, was annoying).
# interface
The UI aesthetics were inspired by the works of:
- Antoine Plu
https://dribbble.com/antoineplu
- Pawel Kuna
https://github.com/codecalm
https://github.com/tabler/tabler
- Pawel Kadysz
https://dribbble.com/pawelkadysz
- Palantir
https://github.com/palantir/blueprint
- Yan Zhu
https://github.com/picturepan2/spectre
The 3-column layout (feeds + items + read) & certain UI/navigation
elements were based on & largely inspired by `Reeder 3`, `NetNewsWire` & `Feedbin`.
Alternative layouts *might* be introduced in the future, but are not guaranteed.
Ideas for 1-column layout:
- stringer
https://github.com/swanson/stringer
- headline
https://github.com/zserge/headline
- miniflux
https://miniflux.app/
Ideas for 2-column layout:
- feedly
https://feedly.com/
- vienna (classic `|-` shaped layout)
https://github.com/ViennaRSS/vienna-rss
# frontend
ES5 is preferred over ES6 until js transpilers (babeljs)
become a thing of the past.
The project won't introduce node/npm ecosystem,
3rd party js code is directly included into the project.
# backend
The reasons for Go:
- single binary compilation
- availability of 3rd party libraries
- the author's excuse to learn go
The reasons for SQLite:
- lack of need for db setup (huge plus for desktop)
- SQL is boring & practical

27
doc/thirdparty.txt Normal file
View File

@@ -0,0 +1,27 @@
Below is the list of 3-rd party code directly included & rewritten
to suit the author's needs & preferences.
The licenses are included, and the authorship comments are left intact.
- readability
https://github.com/miniflux/v2 (commit:31435ef) Apache 2.0
removed goquery dependency
removed assisting utility structs (need another way to debug node scores)
- sanitizer
https://github.com/miniflux/v2 (commit:3cb04b2) Apache 2.0
whitelist changed to the ones from https://github.com/cure53/DOMPurify:
- allowed tags
- allowed uri schemes
- added svg whitelist
- systray
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
removed golog dependency
- fixconsole
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
removed `w32` dependency

0
doc/todo.txt Normal file
View File

View File

@@ -1,9 +1,12 @@
FROM golang:1.15 AS build
RUN apt install gcc -y
FROM golang:alpine AS build
RUN apk add build-base git
WORKDIR /src
COPY . .
RUN make build_linux
FROM ubuntu:20.04
COPY --from=build /src/_output/linux/yarr /usr/bin/yarr
ENTRYPOINT ["/usr/bin/yarr", "-addr", "0.0.0.0:7070"]
FROM alpine:latest
RUN apk add --no-cache ca-certificates && \
update-ca-certificates
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
EXPOSE 7070
CMD ["/usr/local/bin/yarr", "-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]

44
dockerfile.arm Normal file
View File

@@ -0,0 +1,44 @@
FROM ubuntu:20.04
# Install GCC
RUN apt update
RUN apt install -y \
wget build-essential \
gcc-aarch64-linux-gnu \
binutils-aarch64-linux-gnu binutils-aarch64-linux-gnu-dbg \
gcc-arm-linux-gnueabihf \
binutils-arm-linux-gnueabihf binutils-arm-linux-gnueabihf-dbg
RUN env DEBIAN_FRONTEND=noninteractive \
apt install -y qemu-user qemu-user-static
# Install Golang
RUN wget --quiet https://go.dev/dl/go1.18.2.linux-amd64.tar.gz && \
rm -rf /usr/local/go && \
tar -C /usr/local -xzf go1.18.2.linux-amd64.tar.gz
ENV PATH=$PATH:/usr/local/go/bin
# Copy source code
WORKDIR /root/src
RUN mkdir /root/out
COPY . .
# Build ARM64
RUN env \
CC=aarch64-linux-gnu-gcc \
CGO_ENABLED=1 \
GOOS=linux GOARCH=arm64 \
go build \
-tags "sqlite_foreign_keys release linux" \
-ldflags="-s -w" \
-o /root/out/yarr.arm64 src/main.go
RUN env \
CC=arm-linux-gnueabihf-gcc \
CGO_ENABLED=1 \
GOOS=linux GOARCH=arm GOARM=7 \
go build \
-tags "sqlite_foreign_keys release linux" \
-ldflags="-s -w" \
-o /root/out/yarr.arm7 src/main.go
CMD ["/bin/bash"]

23
etc/install-linux.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
cat >"$HOME/.local/share/applications/yarr.desktop" <<END
[Desktop Entry]
Name=yarr
Exec=$HOME/.local/bin/yarr -open
Icon=yarr
Type=Application
Categories=Internet;
END
cat >"$HOME/.local/share/icons/yarr.svg" <<END
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-anchor-favicon">
<circle cx="12" cy="5" r="3" stroke-width="4" stroke="#ffffff"></circle>
<line x1="12" y1="22" x2="12" y2="8" stroke-width="4" stroke="#ffffff"></line>
<path d="M5 12H2a10 10 0 0 0 20 0h-3" stroke-width="4" stroke="#ffffff"></path>
<circle cx="12" cy="5" r="3"></circle>
<line x1="12" y1="22" x2="12" y2="8"></line>
<path d="M5 12H2a10 10 0 0 0 20 0h-3"></path>
</svg>
END

BIN
etc/promo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

13
go.mod
View File

@@ -1,14 +1,11 @@
module github.com/nkanaev/yarr
go 1.14
go 1.17
require (
github.com/PuerkitoBio/goquery v1.5.1
github.com/getlantern/systray v1.0.4
github.com/mattn/go-sqlite3 v1.14.0
github.com/mmcdole/gofeed v1.0.0
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
github.com/mattn/go-sqlite3 v1.14.7
golang.org/x/net v0.8.0
golang.org/x/sys v0.6.0
)
replace github.com/mmcdole/gofeed => ./gofeed
require golang.org/x/text v0.8.0 // indirect

104
go.sum
View File

@@ -1,73 +1,39 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/codegangsta/cli v1.20.0/go.mod h1:/qJNoX69yVSKu5o4jLyXAENLRyk1uhi7zkbQ3slBdOA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.0.4 h1:qJ/bOlYhn5nsj2FejutWWVFMbhOkYhsChoy26OjgZgU=
github.com/getlantern/systray v1.0.4/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mmcdole/gofeed v1.0.0 h1:PHqwr8fsEm8xarj9s53XeEAFYhRM3E9Ib7Ie766/LTE=
github.com/mmcdole/gofeed v1.0.0/go.mod h1:tkVcyzS3qVMlQrQxJoEH1hkTiuo9a8emDzkMi7TZBu0=
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI=
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

52
main.go
View File

@@ -1,52 +0,0 @@
package main
import (
"flag"
"fmt"
"github.com/nkanaev/yarr/server"
"github.com/nkanaev/yarr/storage"
"github.com/nkanaev/yarr/platform"
"log"
"os"
"path/filepath"
)
var Version string = "v0.0"
var GitHash string = "unknown"
func main() {
var addr, storageFile string
var ver bool
flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on")
flag.StringVar(&storageFile, "db", "", "storage file path")
flag.BoolVar(&ver, "version", false, "print application version")
flag.Parse()
if ver {
fmt.Printf("%s (%s)\n", Version, GitHash)
return
}
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile)
configPath, err := os.UserConfigDir()
if err != nil {
logger.Fatal("Failed to get config dir: ", err)
}
if storageFile == "" {
storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil {
logger.Fatal("Failed to create app config dir: ", err)
}
storageFile = filepath.Join(storagePath, "storage.db")
}
db, err := storage.New(storageFile, logger)
if err != nil {
logger.Fatal("Failed to initialise database: ", err)
}
srv := server.New(db, logger, addr)
platform.Start(srv)
}

View File

@@ -1,36 +1,33 @@
VERSION=v1.0
VERSION=2.4
GITHASH=$(shell git rev-parse --short=8 HEAD)
ASSETS = assets/javascripts/* assets/stylesheets/* assets/graphicarts/* assets/index.html
CGO_ENABLED=1
GO_LDFLAGS = -s -w
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
default: bundle
build_default:
mkdir -p _output
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
server/assets_bundle.go: $(ASSETS)
go run scripts/bundle_assets.go >/dev/null
bundle: server/assets_bundle.go
build_macos: bundle
set GOOS=darwin
set GOARCH=amd64
build_macos:
mkdir -p _output/macos
go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr main.go
cp artwork/icon.png _output/macos/icon.png
go run scripts/package_macos.go _output/macos
GOOS=darwin GOARCH=amd64 go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr src/main.go
cp src/platform/icon.png _output/macos/icon.png
go run bin/package_macos.go -outdir _output/macos -version "$(VERSION)"
build_linux: bundle
set GOOS=linux
set GOARCH=386
build_linux:
mkdir -p _output/linux
go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr main.go
GOOS=linux GOARCH=amd64 go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr src/main.go
build_windows: bundle
set GOOS=windows
set GOARCH=386
build_windows:
mkdir -p _output/windows
windres -i artwork/versioninfo.rc -O coff -o platform/versioninfo.syso
go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe main.go
go run bin/generate_versioninfo.go -version "$(VERSION)" -outfile src/platform/versioninfo.rc
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
GOOS=windows GOARCH=amd64 go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe src/main.go
serve:
go run -tags "sqlite_foreign_keys" src/main.go -db local.db
test:
cd src && go test -tags "sqlite_foreign_keys release" ./...

View File

@@ -1,11 +0,0 @@
// +build !windows,!macos
package platform
import (
"github.com/nkanaev/yarr/server"
)
func Start(s *server.Handler) {
s.Start()
}

View File

@@ -1,43 +1,35 @@
# yarr (beta)
# yarr
yet another rss reader.
**yarr** (yet another rss reader) is a web-based feed aggregator which can be used both
as a desktop application and a personal self-hosted server.
![screenshot](https://github.com/nkanaev/yarr/blob/master/artwork/promo.png?raw=true)
The app is a single binary with an embedded database (SQLite).
*yarr* is a server written in Go with the frontend in Vue.js. The storage is backed by SQLite.
![screenshot](etc/promo.png)
The goal of the project is to provide a desktop application accessible via web browser.
Longer-term plans include a self-hosted solution for individuals.
## usage
There are plans to add support for mobile & tablet resolutions.
Support for 3rd-party applications (via Fever API) is being considered.
The latest prebuilt binaries for Linux/MacOS/Windows AMD64 are available
[here](https://github.com/nkanaev/yarr/releases/latest).
## build
### macos
Install `Go >= 1.14` and `gcc`, then run:
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
```sh
git clone https://github.com/nkanaev/yarr.git
git clone https://github.com/nkanaev/gofeed.git
mv gofeed yarr
cd yarr && make build_macos
```
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
## plans
### windows
- test across 3 platforms (macos, linux, windows)
- prebuilt binaries
- GUI-less mode (no tray icon)
- feeds health checker
- mobile & tablet layout
- parameters (`--[no]-gui`, `--addr`, ...)
- Fever API support
- keyboard navigation
Download `yarr-*-windows64.zip`, unzip it, open `yarr.exe`, click the anchor system tray icon, select "Open".
### linux
Download `yarr-*-linux64.zip`, unzip it, place `yarr` in `$HOME/.local/bin`
and run [the script](etc/install-linux.sh).
For self-hosting, see `yarr -h` for auth, tls & server configuration flags.
For building from source code, see [build.md](build.md)
## credits
[Feather](http://feathericons.com/) for icons.
## code of conduct
Be excellent to each other. Party on, dudes!

View File

@@ -1,96 +0,0 @@
package main
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/base64"
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"text/template"
htemplate "html/template"
)
var code_template = `// +build release
// autogenerated. do not edit!
package server
var assets_bundle = map[string]asset{
{{- range .}}
"{{.Name}}": {etag: "{{.Etag}}", body: "{{.Body}}"},
{{- end }}
}
func init() {
assets = assets_bundle
}
`
type asset struct {
Name, Etag, Body string
}
func shasum(b []byte) string {
h := sha256.New()
h.Write(b)
return fmt.Sprintf("%x", h.Sum(nil))[:16]
}
func encode(b []byte) string {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
zw.Write(b)
zw.Close()
return base64.StdEncoding.EncodeToString(buf.Bytes())
}
func main() {
assets := make([]asset, 0)
filepatterns := []string{
"assets/graphicarts/*.svg",
"assets/graphicarts/*.png",
"assets/javascripts/*.js",
"assets/stylesheets/*.css",
"assets/stylesheets/*.map",
}
fmt.Printf("%8s %8s %s\n", "original", "encoded", "filename")
for _, pattern := range filepatterns {
filenames, _ := filepath.Glob(pattern)
for _, filename := range filenames {
content, _ := ioutil.ReadFile(filename)
assets = append(assets, asset{
Name: strings.TrimPrefix(strings.ReplaceAll(filename, "\\", "/"), "assets/"),
Etag: shasum(content),
Body: encode(content),
})
fmt.Printf(
"%8d %8d %s\n",
len(content),
len(assets[len(assets)-1].Body),
filename,
)
}
}
var indexbuf bytes.Buffer
htemplate.Must(htemplate.New("index.html").Delims("{%", "%}").Funcs(htemplate.FuncMap{
"inline": func(svg string) htemplate.HTML {
content, _ := ioutil.ReadFile("assets/graphicarts/" + svg)
return htemplate.HTML(content)
},
}).ParseFiles("assets/index.html")).Execute(&indexbuf, nil)
indexcontent := indexbuf.Bytes()
assets = append(assets, asset{
Name: "index.html",
Etag: shasum(indexcontent),
Body: encode(indexcontent),
})
var buf bytes.Buffer
template := template.Must(template.New("code").Parse(code_template))
template.Execute(&buf, assets)
ioutil.WriteFile("server/assets_bundle.go", buf.Bytes(), 0644)
}

View File

@@ -1,197 +0,0 @@
// +build !windows
// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray)
package server
var Icon []byte = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x45, 0x00, 0x00, 0x00, 0x45,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1c, 0x8d, 0x2b, 0x29, 0x00, 0x00, 0x00,
0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x2e, 0x23, 0x00, 0x00, 0x2e,
0x23, 0x01, 0x78, 0xa5, 0x3f, 0x76, 0x00, 0x00, 0x00, 0x19, 0x74, 0x45,
0x58, 0x74, 0x53, 0x6f, 0x66, 0x74, 0x77, 0x61, 0x72, 0x65, 0x00, 0x77,
0x77, 0x77, 0x2e, 0x69, 0x6e, 0x6b, 0x73, 0x63, 0x61, 0x70, 0x65, 0x2e,
0x6f, 0x72, 0x67, 0x9b, 0xee, 0x3c, 0x1a, 0x00, 0x00, 0x08, 0x55, 0x49,
0x44, 0x41, 0x54, 0x78, 0x9c, 0xed, 0x9c, 0x5d, 0x6c, 0x1c, 0x57, 0x15,
0xc7, 0xff, 0x67, 0x76, 0xd6, 0x93, 0xc4, 0x4e, 0xd6, 0x4d, 0x9b, 0x88,
0x8f, 0x38, 0xa1, 0x84, 0x7e, 0xe1, 0xc4, 0x08, 0xb6, 0xd8, 0xbe, 0x77,
0x97, 0xb0, 0x6d, 0xd3, 0x22, 0x2b, 0xa0, 0x16, 0x42, 0x1d, 0xf5, 0x21,
0xa8, 0x25, 0x2f, 0x85, 0x92, 0x87, 0x20, 0x54, 0x5e, 0x90, 0x50, 0x85,
0x10, 0x1f, 0xad, 0xa0, 0xa4, 0x91, 0x22, 0x54, 0xa0, 0x5f, 0x2f, 0x4d,
0x53, 0x19, 0x22, 0x54, 0xb9, 0x24, 0x4a, 0xb2, 0x75, 0x3c, 0x77, 0x64,
0x8c, 0x23, 0x30, 0x98, 0xd4, 0x11, 0x0d, 0x42, 0x76, 0x2b, 0x25, 0x51,
0xdd, 0x6e, 0x9c, 0x34, 0xde, 0x31, 0xde, 0xc3, 0xc3, 0xce, 0x46, 0x8e,
0x3d, 0x77, 0xf6, 0x6b, 0x66, 0x9d, 0xa2, 0xfc, 0x5e, 0x1c, 0xcf, 0x3d,
0xf7, 0xcc, 0x7f, 0x4e, 0xe6, 0xe3, 0xde, 0x73, 0xcf, 0x35, 0x61, 0x09,
0xc8, 0x64, 0x32, 0xcb, 0x5c, 0xd7, 0xbd, 0x07, 0xc0, 0xfd, 0x00, 0x3e,
0x07, 0xe0, 0x63, 0x00, 0x6e, 0x04, 0xf0, 0x2e, 0x80, 0x77, 0x00, 0x9c,
0x04, 0x70, 0xa8, 0xa9, 0xa9, 0xe9, 0x68, 0x36, 0x9b, 0x9d, 0x69, 0xb4,
0x3e, 0x6a, 0xe4, 0xc9, 0x92, 0xc9, 0x64, 0xdc, 0xb2, 0xac, 0x47, 0x88,
0xe8, 0x09, 0x66, 0xfe, 0x48, 0x05, 0x5d, 0xde, 0x05, 0xf0, 0x64, 0x22,
0x91, 0x78, 0xba, 0xbf, 0xbf, 0x3f, 0x1f, 0xb5, 0xbe, 0x12, 0x0d, 0x0b,
0x4a, 0x57, 0x57, 0xd7, 0x2d, 0xb1, 0x58, 0xec, 0x10, 0x80, 0x3b, 0x6a,
0xe8, 0x3e, 0xc6, 0xcc, 0x0f, 0x38, 0x8e, 0xf3, 0xaf, 0xb0, 0x75, 0xf9,
0xd1, 0x90, 0xa0, 0x08, 0x21, 0xbe, 0x48, 0x44, 0x7d, 0x00, 0x56, 0xd7,
0xe1, 0x66, 0x8a, 0x99, 0xbf, 0xe6, 0x38, 0xce, 0x1b, 0x61, 0xe9, 0xd2,
0x11, 0x79, 0x50, 0x84, 0x10, 0xb7, 0x13, 0x91, 0x03, 0xa0, 0x35, 0x04,
0x77, 0x17, 0x88, 0x48, 0xda, 0xb6, 0x3d, 0x16, 0x82, 0x2f, 0x2d, 0x46,
0x94, 0xce, 0x3b, 0x3a, 0x3a, 0x9a, 0x89, 0xe8, 0x8f, 0x08, 0x27, 0x20,
0x00, 0xb0, 0x8a, 0x99, 0xfb, 0x92, 0xc9, 0xe4, 0x8a, 0x90, 0xfc, 0xf9,
0x62, 0x46, 0xe9, 0xbc, 0xb9, 0xb9, 0xf9, 0xbb, 0x00, 0x3e, 0x55, 0xc6,
0x6c, 0x02, 0x40, 0x16, 0xc0, 0x79, 0x00, 0x6b, 0x01, 0x64, 0x00, 0xac,
0x0b, 0xb0, 0xbf, 0xd5, 0xb2, 0xac, 0x3d, 0x00, 0x7e, 0x1c, 0x82, 0x44,
0x5f, 0x22, 0x7b, 0x7c, 0x3a, 0x3b, 0x3b, 0x6f, 0x34, 0x4d, 0xf3, 0x0c,
0x80, 0x55, 0x1a, 0x93, 0xf3, 0x44, 0xb4, 0xdb, 0xb6, 0xed, 0x57, 0x00,
0xf0, 0x7c, 0x4d, 0x52, 0xca, 0x1d, 0x00, 0x9e, 0x01, 0x70, 0x93, 0xa6,
0xef, 0x05, 0x66, 0xbe, 0xd9, 0x71, 0x9c, 0xa9, 0x10, 0x25, 0x5f, 0x21,
0xb2, 0xc7, 0x27, 0x1e, 0x8f, 0x7f, 0x15, 0xfa, 0x80, 0x9c, 0x05, 0x20,
0x6c, 0xdb, 0x3e, 0x80, 0xab, 0x03, 0x02, 0x00, 0xac, 0x94, 0x7a, 0x19,
0x40, 0x37, 0x80, 0x73, 0x9a, 0xfe, 0xab, 0x88, 0xe8, 0xfe, 0x70, 0x94,
0x2e, 0x26, 0xb2, 0xa0, 0x30, 0xb3, 0x56, 0x34, 0x33, 0x3f, 0xac, 0x94,
0x7a, 0x2b, 0xa8, 0xbf, 0xd7, 0xfe, 0xcd, 0x00, 0x93, 0x07, 0x6a, 0xd5,
0x56, 0x8e, 0x28, 0x5f, 0xb4, 0xdd, 0x9a, 0xe3, 0x27, 0x1d, 0xc7, 0x79,
0xbd, 0x12, 0x07, 0x4a, 0xa9, 0xd7, 0x00, 0xfc, 0xb5, 0x4a, 0xff, 0x75,
0x13, 0x49, 0x50, 0x7a, 0x7a, 0x7a, 0x2c, 0x14, 0x87, 0xed, 0x7e, 0x54,
0x14, 0x90, 0x12, 0x44, 0xa4, 0xb3, 0x5f, 0xd3, 0xde, 0xde, 0xde, 0x54,
0x95, 0xb0, 0x0a, 0x89, 0x24, 0x28, 0x53, 0x53, 0x53, 0x6b, 0xa0, 0x79,
0x89, 0x33, 0xf3, 0x3b, 0x55, 0xba, 0x7b, 0x5b, 0x73, 0x9c, 0x5a, 0x5a,
0x5a, 0xd6, 0x56, 0xe9, 0xab, 0x22, 0x22, 0x09, 0x8a, 0x69, 0x9a, 0x39,
0x5d, 0x1b, 0x11, 0x55, 0x35, 0x66, 0x61, 0x66, 0xad, 0xfd, 0xf2, 0xe5,
0xcb, 0xdf, 0xaf, 0xc6, 0x57, 0xa5, 0x44, 0x12, 0x14, 0xdb, 0xb6, 0xa7,
0x01, 0x4c, 0xfb, 0xb5, 0x31, 0x73, 0xaa, 0x4a, 0x77, 0x5f, 0xd0, 0x1c,
0xcf, 0x65, 0xb3, 0xd9, 0x8b, 0x55, 0xfa, 0xaa, 0x88, 0x28, 0x5f, 0xb4,
0xbe, 0x43, 0x71, 0x22, 0xba, 0x37, 0x95, 0x4a, 0xdd, 0x56, 0x89, 0x83,
0xee, 0xee, 0xee, 0x3b, 0x00, 0xdc, 0x5d, 0x8d, 0xff, 0x30, 0x88, 0xf2,
0x93, 0x7c, 0x48, 0xd3, 0x64, 0x32, 0xf3, 0x73, 0x99, 0x4c, 0x66, 0x59,
0x50, 0xff, 0x4c, 0x26, 0xb3, 0x8c, 0x88, 0x9e, 0x83, 0x7e, 0xd4, 0xad,
0xf3, 0x5f, 0x37, 0x91, 0x05, 0x25, 0x16, 0x8b, 0xf5, 0x01, 0x28, 0x68,
0x9a, 0x85, 0xeb, 0xba, 0x87, 0xb7, 0x6c, 0xd9, 0xd2, 0xe6, 0xd7, 0x98,
0x4e, 0xa7, 0xd7, 0xbb, 0xae, 0x7b, 0x84, 0x88, 0xba, 0x34, 0xfd, 0xe7,
0x98, 0xb9, 0x2f, 0x14, 0xa1, 0x3e, 0x44, 0x3a, 0x4b, 0x4e, 0xa5, 0x52,
0x2f, 0x30, 0xf3, 0x37, 0x02, 0x4c, 0x2e, 0x33, 0xf3, 0x2b, 0x44, 0x34,
0x02, 0x60, 0x39, 0x80, 0xcb, 0x44, 0x74, 0x27, 0x33, 0xf7, 0x02, 0x08,
0xba, 0x93, 0x9e, 0x57, 0x4a, 0x3d, 0x12, 0xaa, 0xd8, 0x79, 0x44, 0x1a,
0x14, 0x29, 0xe5, 0x06, 0x66, 0x1e, 0x23, 0xa2, 0xe6, 0x10, 0xdd, 0x5e,
0x34, 0x4d, 0xf3, 0xd3, 0x03, 0x03, 0x03, 0x13, 0x21, 0xfa, 0xbc, 0x8a,
0x48, 0x53, 0x07, 0x4a, 0xa9, 0xff, 0x00, 0xd8, 0x09, 0xfd, 0x63, 0x54,
0x2d, 0x4c, 0x44, 0xbb, 0xa2, 0x0c, 0x08, 0x00, 0xc4, 0xa2, 0x74, 0x0e,
0x00, 0x93, 0x93, 0x93, 0x6f, 0xb6, 0xb5, 0xb5, 0xcd, 0x00, 0xd8, 0x8a,
0xfa, 0xee, 0x4c, 0x26, 0xa2, 0xef, 0xdb, 0xb6, 0xfd, 0x6c, 0x48, 0xd2,
0xb4, 0x44, 0x1e, 0x14, 0x00, 0x98, 0x98, 0x98, 0xb0, 0xd7, 0xaf, 0x5f,
0x7f, 0x0a, 0xc0, 0x36, 0x00, 0xf1, 0x1a, 0x5c, 0xcc, 0x00, 0xd8, 0xa5,
0x94, 0xda, 0x1f, 0xae, 0x32, 0x7f, 0x1a, 0x12, 0x14, 0x00, 0x98, 0x98,
0x98, 0x18, 0x5b, 0xb7, 0x6e, 0x5d, 0x1f, 0x11, 0xb5, 0x01, 0xb8, 0xbd,
0x8a, 0xae, 0x7f, 0x20, 0xa2, 0xaf, 0x2b, 0xa5, 0x8e, 0x45, 0xa5, 0x6d,
0x21, 0x0d, 0x5d, 0xe2, 0x28, 0x21, 0x84, 0xe8, 0x34, 0x0c, 0xe3, 0x21,
0x66, 0x7e, 0x14, 0xfe, 0x5f, 0x99, 0x19, 0x66, 0xde, 0x0f, 0xe0, 0x65,
0xc7, 0x71, 0xfe, 0xdc, 0x60, 0x79, 0x4b, 0x13, 0x94, 0x12, 0x52, 0xca,
0x13, 0x00, 0xd2, 0x3e, 0x4d, 0x83, 0x4a, 0x29, 0xdd, 0xf0, 0x3e, 0x72,
0x22, 0xfd, 0xfa, 0x7c, 0x58, 0xb9, 0x1e, 0x14, 0x1f, 0xae, 0x07, 0xc5,
0x87, 0xeb, 0x41, 0xf1, 0xe1, 0x7a, 0x50, 0x7c, 0x30, 0xa5, 0x94, 0x7f,
0x22, 0xa2, 0xaa, 0x82, 0xc3, 0xcc, 0x13, 0xcc, 0xfc, 0x4b, 0xc7, 0x71,
0xfe, 0x1e, 0x95, 0xb0, 0x7a, 0x11, 0x42, 0x6c, 0x26, 0xa2, 0x3d, 0xde,
0xb8, 0xa8, 0x62, 0x98, 0xb9, 0x60, 0x02, 0xd8, 0xc2, 0xcc, 0x81, 0xb9,
0x0d, 0x3f, 0x88, 0x68, 0x87, 0x94, 0x32, 0xa3, 0x94, 0x1a, 0xae, 0xb6,
0x6f, 0xd4, 0x48, 0x29, 0x3f, 0x8f, 0xe2, 0xaa, 0xe3, 0x0a, 0xe6, 0x85,
0xcb, 0x4a, 0x65, 0xb9, 0x6c, 0x00, 0xa8, 0xb5, 0xee, 0x63, 0x05, 0x33,
0x3f, 0x51, 0x63, 0xdf, 0x48, 0xf1, 0x74, 0xd5, 0xba, 0xde, 0x9c, 0x37,
0x00, 0xb8, 0xb5, 0x9e, 0x9c, 0x88, 0x6a, 0xa9, 0x35, 0x89, 0x9c, 0x3a,
0x75, 0xe5, 0xeb, 0xb9, 0x53, 0xc0, 0xcc, 0xa7, 0xea, 0x38, 0x79, 0x64,
0xd4, 0xa9, 0xcb, 0xad, 0xe7, 0x4e, 0xf9, 0x80, 0x88, 0x7e, 0x58, 0xc7,
0xc9, 0x23, 0xc3, 0xd3, 0xf5, 0x41, 0x8d, 0xdd, 0xf3, 0x26, 0xf4, 0x77,
0xca, 0x7b, 0x5e, 0x9a, 0x70, 0x11, 0xd7, 0xfa, 0xd7, 0x47, 0x29, 0x35,
0x2c, 0x84, 0xe8, 0x0e, 0xfa, 0xfa, 0x30, 0x73, 0x12, 0xc0, 0x0d, 0x3e,
0x4d, 0x79, 0x13, 0xc5, 0xba, 0x10, 0xbf, 0x67, 0xf0, 0xac, 0x6d, 0xdb,
0xf7, 0x86, 0xa8, 0xb5, 0xa1, 0x78, 0xff, 0x61, 0xda, 0x05, 0x7a, 0x29,
0xe5, 0x69, 0xf8, 0x07, 0xe5, 0x9c, 0x01, 0x60, 0x5c, 0xd3, 0x6f, 0x63,
0x32, 0x99, 0xac, 0x25, 0x21, 0x74, 0xcd, 0xe3, 0x5d, 0xd7, 0x27, 0xfc,
0xda, 0x98, 0x79, 0xdc, 0x00, 0xf0, 0xa6, 0xa6, 0x6f, 0xdc, 0xb2, 0xac,
0xf6, 0xa8, 0x84, 0x2d, 0x25, 0xf1, 0x78, 0x7c, 0x33, 0xf4, 0x19, 0xc0,
0x71, 0x83, 0x99, 0xb5, 0x2b, 0x6d, 0x44, 0x74, 0x57, 0x34, 0xb2, 0x96,
0x16, 0x22, 0xd2, 0xad, 0x3a, 0xc2, 0x30, 0x8c, 0x31, 0xc3, 0x30, 0x0c,
0x05, 0x60, 0xd6, 0xcf, 0x80, 0x99, 0xb5, 0x9d, 0x3f, 0xe4, 0xe8, 0xae,
0xcb, 0x9d, 0x9e, 0x9e, 0x56, 0x86, 0xb7, 0x18, 0xfe, 0x17, 0x8d, 0xd1,
0xd6, 0x4c, 0x26, 0x13, 0x56, 0x65, 0xe3, 0x35, 0x81, 0x10, 0x62, 0x75,
0xc0, 0x9d, 0x32, 0x34, 0x3a, 0x3a, 0x7a, 0xa9, 0x34, 0x11, 0x3c, 0xaa,
0x31, 0x5a, 0xe6, 0xba, 0x6e, 0x6f, 0x04, 0xda, 0x96, 0x0c, 0xc3, 0x30,
0x7a, 0x01, 0x58, 0x9a, 0xe6, 0x63, 0x80, 0x97, 0x3a, 0x98, 0x9b, 0x9b,
0x3b, 0x18, 0xe0, 0x67, 0x57, 0xc8, 0xba, 0x96, 0x14, 0x66, 0xd6, 0x5e,
0x0f, 0x11, 0x1d, 0x04, 0xbc, 0xa0, 0x0c, 0x0d, 0x0d, 0x8d, 0x02, 0xf8,
0x9b, 0xc6, 0xb6, 0x53, 0x4a, 0xf9, 0x7f, 0xf1, 0x6e, 0x91, 0x52, 0xde,
0x07, 0xe0, 0x4e, 0x4d, 0xf3, 0xc9, 0x52, 0x25, 0xf7, 0xfc, 0x3c, 0xca,
0x8b, 0x01, 0xfe, 0x7e, 0x10, 0x96, 0xb0, 0x25, 0x46, 0x7b, 0x1d, 0xcc,
0xfc, 0x52, 0xe9, 0xdf, 0x57, 0x82, 0x92, 0xcf, 0xe7, 0x7f, 0x0b, 0x40,
0x57, 0x96, 0x75, 0x97, 0x94, 0x32, 0xb2, 0xba, 0xd5, 0x46, 0x20, 0xa5,
0xdc, 0x0e, 0x7d, 0x55, 0xd4, 0x85, 0x58, 0x2c, 0xf6, 0x42, 0xe9, 0x97,
0x2b, 0x41, 0x19, 0x19, 0x19, 0xc9, 0x01, 0xf8, 0x75, 0x80, 0xdf, 0xbd,
0x1d, 0x1d, 0x1d, 0x61, 0x56, 0x0f, 0x34, 0x0c, 0xaf, 0x96, 0xff, 0xa9,
0x00, 0x93, 0x67, 0x06, 0x07, 0x07, 0xdf, 0x2b, 0xfd, 0x72, 0x55, 0x1a,
0x72, 0x6e, 0x6e, 0xee, 0x17, 0x00, 0x2e, 0x6b, 0x3a, 0xae, 0x5f, 0xb9,
0x72, 0xe5, 0xcf, 0xea, 0x97, 0xd8, 0x78, 0x2c, 0xcb, 0x7a, 0x0a, 0xfa,
0x61, 0xfd, 0xa5, 0x58, 0x2c, 0xf6, 0xab, 0xf9, 0xc7, 0xae, 0x0a, 0xca,
0xd0, 0xd0, 0xd0, 0x59, 0x00, 0x3f, 0xd7, 0x39, 0x67, 0xe6, 0xc7, 0x84,
0x10, 0x0f, 0x85, 0xa0, 0xb3, 0x61, 0x08, 0x21, 0x1e, 0x04, 0xf0, 0x2d,
0x5d, 0x3b, 0x11, 0xfd, 0xe4, 0xc4, 0x89, 0x13, 0xe7, 0xe7, 0x1f, 0x5b,
0x94, 0xb0, 0x6e, 0x6a, 0x6a, 0xfa, 0x29, 0x00, 0x6d, 0x89, 0x38, 0x11,
0xed, 0x97, 0x52, 0x6e, 0xaa, 0x47, 0x68, 0xa3, 0xf0, 0x92, 0xd7, 0xbf,
0x09, 0x30, 0x39, 0x9d, 0x48, 0x24, 0x16, 0x3d, 0x56, 0x8b, 0x82, 0x92,
0xcd, 0x66, 0x67, 0x88, 0x68, 0x77, 0x80, 0xa3, 0x04, 0x80, 0xd7, 0xa5,
0x94, 0x1b, 0x6a, 0xd0, 0xd9, 0x30, 0xa4, 0x94, 0x1b, 0xbc, 0x6a, 0x6d,
0xdd, 0xa6, 0x09, 0x26, 0xa2, 0xef, 0xf8, 0xed, 0x4d, 0xf4, 0x5d, 0xda,
0xb0, 0x6d, 0xbb, 0x9f, 0x99, 0xf7, 0x05, 0x9c, 0xf3, 0xe3, 0x00, 0x8e,
0x5c, 0xab, 0x81, 0xf1, 0x74, 0x1d, 0x41, 0x71, 0x17, 0xab, 0x8e, 0xbd,
0xb6, 0x6d, 0x1f, 0xf1, 0x6b, 0xd0, 0xae, 0xf7, 0xb4, 0xb6, 0xb6, 0x7e,
0x0f, 0x80, 0x6f, 0xe6, 0xcd, 0xe3, 0x16, 0x00, 0x4e, 0x3a, 0x9d, 0xfe,
0x4c, 0x25, 0x42, 0x1b, 0x45, 0x2a, 0x95, 0x6a, 0x07, 0x30, 0x88, 0xa2,
0x3e, 0x1d, 0xc3, 0xb9, 0x5c, 0xee, 0x71, 0x5d, 0xa3, 0x36, 0x28, 0xfd,
0xfd, 0xfd, 0x79, 0x6f, 0x9e, 0x70, 0x5e, 0x67, 0x03, 0xe0, 0xa3, 0x85,
0x42, 0xe1, 0x0d, 0x6f, 0x0c, 0xb0, 0xe4, 0xa4, 0x52, 0xa9, 0x5e, 0x66,
0x56, 0x08, 0xde, 0x59, 0x76, 0xce, 0x34, 0xcd, 0x1d, 0x63, 0x63, 0x63,
0xda, 0xdc, 0x74, 0xe0, 0xca, 0xe0, 0xe0, 0xe0, 0xe0, 0x19, 0x14, 0x4b,
0xb2, 0x82, 0xca, 0xbd, 0x13, 0x00, 0x5e, 0x4d, 0xa5, 0x52, 0xfb, 0xa2,
0xde, 0xdb, 0xa7, 0xa3, 0xa3, 0xa3, 0xa3, 0x59, 0x08, 0xb1, 0x9f, 0x99,
0x0f, 0x40, 0xff, 0x0e, 0x01, 0x80, 0x8b, 0x44, 0xb4, 0x6d, 0x60, 0x60,
0xe0, 0xdf, 0x41, 0xfe, 0xca, 0x2e, 0x97, 0x7a, 0x2b, 0x80, 0xdb, 0x51,
0x26, 0xeb, 0xcf, 0xcc, 0x8f, 0x59, 0x96, 0xf5, 0x4f, 0x21, 0x44, 0x64,
0x9b, 0x93, 0xfc, 0x90, 0x52, 0x6e, 0x6f, 0x69, 0x69, 0x39, 0x45, 0x44,
0x8f, 0x96, 0x31, 0x75, 0x01, 0x6c, 0xb7, 0x6d, 0x5b, 0x97, 0x26, 0xb9,
0x42, 0x45, 0x6b, 0xc8, 0x4a, 0xa9, 0xc3, 0x00, 0xbe, 0x82, 0xe0, 0x3b,
0x06, 0x00, 0x36, 0x10, 0xd1, 0xef, 0x85, 0x10, 0xc7, 0xbb, 0xbb, 0xbb,
0xb7, 0x56, 0xe2, 0xbb, 0x56, 0xa4, 0x94, 0xf7, 0x49, 0x29, 0x07, 0x00,
0xbc, 0x0a, 0xa0, 0xdc, 0x7a, 0xf1, 0x45, 0x22, 0xfa, 0xb2, 0x77, 0x1d,
0x65, 0xa9, 0x78, 0xb7, 0xa9, 0x52, 0xea, 0xb0, 0x37, 0x5b, 0x7e, 0x0d,
0xc0, 0x9a, 0x20, 0x5b, 0x22, 0xca, 0x10, 0x51, 0x46, 0x4a, 0x39, 0x4c,
0x44, 0xbf, 0x23, 0xa2, 0x03, 0xf3, 0x87, 0xd1, 0xb5, 0x22, 0x84, 0x58,
0x6d, 0x18, 0x46, 0xaf, 0x37, 0xfd, 0xd7, 0xcd, 0x76, 0x17, 0x72, 0x8e,
0x88, 0xb6, 0x55, 0x72, 0x87, 0x94, 0xa8, 0xba, 0xe6, 0x2d, 0x9d, 0x4e,
0x7f, 0xb2, 0x50, 0x28, 0x1c, 0xa8, 0x42, 0x14, 0x00, 0xe4, 0x99, 0xf9,
0x18, 0x11, 0x1d, 0x65, 0xe6, 0x63, 0xae, 0xeb, 0xfe, 0x63, 0x64, 0x64,
0x64, 0xb6, 0x5c, 0xcd, 0x5b, 0x32, 0x99, 0x8c, 0xc7, 0xe3, 0xf1, 0xcd,
0x5e, 0xa6, 0xec, 0x6e, 0xef, 0xa7, 0x2e, 0x41, 0xe4, 0xc7, 0xb0, 0x69,
0x9a, 0x3b, 0xca, 0xbd, 0x43, 0x16, 0x52, 0x53, 0x21, 0x60, 0x4f, 0x4f,
0x8f, 0x95, 0xcb, 0xe5, 0x9e, 0x04, 0x10, 0x34, 0xc8, 0x0b, 0xe2, 0xbf,
0x00, 0xce, 0xa0, 0xb8, 0xc5, 0xd6, 0x6f, 0xab, 0xff, 0x14, 0x8a, 0x7f,
0xfc, 0xe1, 0x66, 0xd4, 0xb6, 0x77, 0x9a, 0x01, 0xec, 0xcd, 0xe5, 0x72,
0x8f, 0x07, 0x7d, 0x65, 0x74, 0xd4, 0x55, 0x1d, 0x29, 0x84, 0xf8, 0x12,
0x11, 0xed, 0x43, 0xf9, 0x0d, 0xd9, 0x8d, 0xe4, 0x34, 0x80, 0xdd, 0x95,
0xbe, 0x3f, 0xfc, 0xa8, 0xab, 0xb8, 0x78, 0x72, 0x72, 0xf2, 0xad, 0x8d,
0x1b, 0x37, 0x3e, 0x5b, 0x28, 0x14, 0x66, 0x01, 0x74, 0xa1, 0xb6, 0x6a,
0xea, 0x50, 0x60, 0xe6, 0x4b, 0x44, 0xf4, 0xa3, 0x44, 0x22, 0xb1, 0xf3,
0xf8, 0xf1, 0xe3, 0xa7, 0xeb, 0xf1, 0x15, 0x5a, 0x1d, 0xad, 0x94, 0x72,
0x2d, 0x11, 0xed, 0x61, 0xe6, 0x6f, 0x23, 0x78, 0xac, 0x10, 0x36, 0x17,
0x00, 0xec, 0x8b, 0xc5, 0x62, 0x4f, 0x2f, 0x9c, 0xed, 0xd6, 0x4a, 0xe8,
0xc5, 0xc5, 0xe9, 0x74, 0xfa, 0x06, 0x66, 0x7e, 0x98, 0x99, 0x77, 0x02,
0xf8, 0x6c, 0xd8, 0xfe, 0xe7, 0x71, 0x92, 0x99, 0x5f, 0xb2, 0x2c, 0xeb,
0xf9, 0x6c, 0x36, 0x1b, 0xea, 0x06, 0xcb, 0xa8, 0xf7, 0xfb, 0x6c, 0x62,
0xe6, 0x07, 0x0d, 0xc3, 0xb8, 0x87, 0x99, 0x3b, 0x51, 0xdf, 0xe3, 0x35,
0x0b, 0x60, 0x08, 0xc0, 0x51, 0x22, 0x3a, 0x18, 0xe5, 0x9f, 0x0b, 0x69,
0x58, 0x19, 0x7a, 0x26, 0x93, 0x69, 0x99, 0x9d, 0x9d, 0x15, 0x00, 0x36,
0x01, 0xb8, 0x8d, 0x99, 0x6f, 0x45, 0x71, 0x16, 0x7b, 0x13, 0x11, 0xb5,
0x32, 0x73, 0x8c, 0x88, 0xe6, 0x98, 0xf9, 0x7d, 0x14, 0xbf, 0x3c, 0x6f,
0x33, 0xf3, 0x38, 0x80, 0x71, 0xc3, 0x30, 0xc6, 0xa6, 0xa7, 0xa7, 0xd5,
0xe8, 0xe8, 0xe8, 0xa5, 0x46, 0x68, 0xfd, 0x1f, 0x32, 0x61, 0xc5, 0x44,
0x9c, 0xe8, 0x1b, 0xc4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
0xae, 0x42, 0x60, 0x82,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,271 +0,0 @@
package server
import (
"bytes"
"errors"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/mmcdole/gofeed"
"github.com/nkanaev/yarr/storage"
"io/ioutil"
"net/http"
"net/url"
"time"
)
type FeedSource struct {
Title string `json:"title"`
Url string `json:"url"`
}
const feedLinks = `
link[type='application/rss+xml'],
link[type='application/atom+xml'],
a[href$="/feed"],
a[href$="/feed/"],
a[href$="feed.xml"],
a[href$="atom.xml"],
a[href$="rss.xml"],
a:contains("rss"),
a:contains("RSS"),
a:contains("feed"),
a:contains("FEED")
`
type Client struct {
httpClient *http.Client
userAgent string
}
func (c *Client) get(url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
return c.httpClient.Do(req)
}
var defaultClient *Client
func searchFeedLinks(html []byte, siteurl string) ([]FeedSource, error) {
sources := make([]FeedSource, 0, 0)
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(html))
if err != nil {
return sources, err
}
base, err := url.Parse(siteurl)
if err != nil {
return sources, err
}
// feed {url: title} map
feeds := make(map[string]string)
doc.Find(feedLinks).Each(func(i int, s *goquery.Selection) {
// Unlikely to happen, but don't get more than N links
if len(feeds) > 10 {
return
}
if href, ok := s.Attr("href"); ok {
feedUrl, err := url.Parse(href)
if err != nil {
return
}
title := s.AttrOr("title", "")
url := base.ResolveReference(feedUrl).String()
if _, alreadyExists := feeds[url]; alreadyExists {
if feeds[url] == "" {
feeds[url] = title
}
} else {
feeds[url] = title
}
}
})
for url, title := range feeds {
sources = append(sources, FeedSource{Title: title, Url: url})
}
return sources, nil
}
func discoverFeed(candidateUrl string) (*gofeed.Feed, *[]FeedSource, error) {
// Query URL
res, err := defaultClient.get(candidateUrl)
if err != nil {
return nil, nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
errmsg := fmt.Sprintf("Failed to fetch feed %s (status: %d)", candidateUrl, res.StatusCode)
return nil, nil, errors.New(errmsg)
}
content, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, nil, err
}
// Try to feed into parser
feedparser := gofeed.NewParser()
feed, err := feedparser.Parse(bytes.NewReader(content))
if err == nil {
// WILD: feeds may not always have link to themselves
if len(feed.FeedLink) == 0 {
feed.FeedLink = candidateUrl
}
// WILD: resolve relative links (path, without host)
base, _ := url.Parse(candidateUrl)
if link, err := url.Parse(feed.Link); err == nil && link.Host == "" {
feed.Link = base.ResolveReference(link).String()
}
if link, err := url.Parse(feed.FeedLink); err == nil && link.Host == "" {
feed.FeedLink = base.ResolveReference(link).String()
}
return feed, nil, nil
}
// Possibly an html link. Search for feed links
sources, err := searchFeedLinks(content, candidateUrl)
if err != nil {
return nil, nil, err
} else if len(sources) == 0 {
return nil, nil, errors.New("No feeds found at the given url")
} else if len(sources) == 1 {
if sources[0].Url == candidateUrl {
return nil, nil, errors.New("Recursion!")
}
return discoverFeed(sources[0].Url)
}
return nil, &sources, nil
}
func findFavicon(websiteUrl, feedUrl string) (*[]byte, error) {
candidateUrls := make([]string, 0)
favicon := func(link string) string {
u, err := url.Parse(link)
if err != nil {
return ""
}
return fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host)
}
if len(websiteUrl) != 0 {
res, err := defaultClient.get(websiteUrl)
if err != nil {
return nil, err
}
defer res.Body.Close()
doc, err := goquery.NewDocumentFromReader(res.Body)
base, err := url.Parse(websiteUrl)
if err != nil {
return nil, err
}
doc.Find(`link[rel=icon]`).EachWithBreak(func(i int, s *goquery.Selection) bool {
if href, ok := s.Attr("href"); ok {
if hrefUrl, err := url.Parse(href); err == nil {
faviconUrl := base.ResolveReference(hrefUrl).String()
candidateUrls = append(candidateUrls, faviconUrl)
}
}
return true
})
if c := favicon(websiteUrl); len(c) != 0 {
candidateUrls = append(candidateUrls, c)
}
}
if c := favicon(feedUrl); len(c) != 0 {
candidateUrls = append(candidateUrls, c)
}
imageTypes := [4]string{
"image/x-icon",
"image/png",
"image/jpeg",
"image/gif",
}
for _, url := range candidateUrls {
res, err := defaultClient.get(url)
if err != nil {
continue
}
defer res.Body.Close()
if res.StatusCode == 200 {
if content, err := ioutil.ReadAll(res.Body); err == nil {
ctype := http.DetectContentType(content)
for _, itype := range imageTypes {
if ctype == itype {
return &content, nil
}
}
}
}
}
return nil, nil
}
func convertItems(items []*gofeed.Item, feed storage.Feed) []storage.Item {
result := make([]storage.Item, len(items))
for i, item := range items {
imageURL := ""
if item.Image != nil {
imageURL = item.Image.URL
}
author := ""
if item.Author != nil {
author = item.Author.Name
}
result[i] = storage.Item{
GUID: item.GUID,
FeedId: feed.Id,
Title: item.Title,
Link: item.Link,
Description: item.Description,
Content: item.Content,
Author: author,
Date: item.PublishedParsed,
DateUpdated: item.UpdatedParsed,
Status: storage.UNREAD,
Image: imageURL,
}
}
return result
}
func listItems(f storage.Feed) ([]storage.Item, error) {
res, err := defaultClient.get(f.FeedLink)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == 404 {
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: 404)", f.FeedLink)
return nil, errors.New(errmsg)
}
feedparser := gofeed.NewParser()
feed, err := feedparser.Parse(res.Body)
if err != nil {
return nil, err
}
return convertItems(feed.Items, f), nil
}
func init() {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DisableKeepAlives = true
httpClient := &http.Client{
Timeout: time.Second * 5,
Transport: transport,
}
defaultClient = &Client{
httpClient: httpClient,
userAgent: "Yarr/1.0",
}
}

View File

@@ -1,482 +0,0 @@
package server
import (
"bytes"
"encoding/json"
"encoding/base64"
"compress/gzip"
"fmt"
"github.com/nkanaev/yarr/storage"
"html"
"html/template"
"io"
"io/ioutil"
"math"
"mime"
"net/http"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
)
var routes []Route = []Route{
p("/", IndexHandler),
p("/static/*path", StaticHandler),
p("/api/status", StatusHandler),
p("/api/folders", FolderListHandler),
p("/api/folders/:id", FolderHandler),
p("/api/feeds", FeedListHandler),
p("/api/feeds/find", FeedHandler),
p("/api/feeds/refresh", FeedRefreshHandler),
p("/api/feeds/:id/icon", FeedIconHandler),
p("/api/feeds/:id", FeedHandler),
p("/api/items", ItemListHandler),
p("/api/items/:id", ItemHandler),
p("/api/settings", SettingsHandler),
p("/opml/import", OPMLImportHandler),
p("/opml/export", OPMLExportHandler),
p("/page", PageCrawlHandler),
}
type asset struct {
etag string
body string // base64(gzip(content))
gzipped *[]byte
decoded *string
}
func (a *asset) gzip() *[]byte {
if a.gzipped == nil {
gzipped, _ := base64.StdEncoding.DecodeString(a.body)
a.gzipped = &gzipped
}
return a.gzipped
}
func (a *asset) text() *string {
if a.decoded == nil {
gzipped, _ := base64.StdEncoding.DecodeString(a.body)
reader, _ := gzip.NewReader(bytes.NewBuffer(gzipped))
decoded, _ := ioutil.ReadAll(reader)
reader.Close()
decoded_string := string(decoded)
a.decoded = &decoded_string
}
return a.decoded
}
var assets map[string]asset
type FolderCreateForm struct {
Title string `json:"title"`
}
type FolderUpdateForm struct {
Title *string `json:"title,omitempty"`
IsExpanded *bool `json:"is_expanded,omitempty"`
}
type FeedCreateForm struct {
Url string `json:"url"`
FolderID *int64 `json:"folder_id,omitempty"`
}
type ItemUpdateForm struct {
Status *storage.ItemStatus `json:"status,omitempty"`
}
func IndexHandler(rw http.ResponseWriter, req *http.Request) {
if assets != nil {
asset := assets["index.html"]
rw.Header().Set("Content-Type", "text/html")
rw.Header().Set("Content-Encoding", "gzip")
rw.Write(*asset.gzip())
} else {
t := template.Must(template.New("index.html").Delims("{%", "%}").Funcs(template.FuncMap{
"inline": func(svg string) template.HTML {
content, _ := ioutil.ReadFile("assets/graphicarts/" + svg)
return template.HTML(content)
},
}).ParseFiles("assets/index.html"))
rw.Header().Set("Content-Type", "text/html")
t.Execute(rw, nil)
}
}
func StaticHandler(rw http.ResponseWriter, req *http.Request) {
path := Vars(req)["path"]
ctype := mime.TypeByExtension(filepath.Ext(path))
if assets != nil {
if asset, ok := assets[path]; ok {
if req.Header.Get("if-none-match") == asset.etag {
rw.WriteHeader(http.StatusNotModified)
return
}
rw.Header().Set("Content-Type", ctype)
rw.Header().Set("Content-Encoding", "gzip")
rw.Header().Set("Etag", asset.etag)
rw.Write(*asset.gzip())
}
}
f, err := os.Open("assets/" + path)
if err != nil {
return
}
defer f.Close()
rw.Header().Set("Content-Type", ctype)
io.Copy(rw, f)
}
func StatusHandler(rw http.ResponseWriter, req *http.Request) {
writeJSON(rw, map[string]interface{}{
"running": *handler(req).queueSize,
"stats": db(req).FeedStats(),
})
}
func FolderListHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
list := db(req).ListFolders()
writeJSON(rw, list)
} else if req.Method == "POST" {
var body FolderCreateForm
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
handler(req).log.Print(err)
rw.WriteHeader(http.StatusBadRequest)
return
}
if len(body.Title) == 0 {
rw.WriteHeader(http.StatusBadRequest)
writeJSON(rw, map[string]string{"error": "Folder title missing."})
return
}
folder := db(req).CreateFolder(body.Title)
rw.WriteHeader(http.StatusCreated)
writeJSON(rw, folder)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
}
func FolderHandler(rw http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
if req.Method == "PUT" {
var body FolderUpdateForm
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
handler(req).log.Print(err)
rw.WriteHeader(http.StatusBadRequest)
return
}
if body.Title != nil {
db(req).RenameFolder(id, *body.Title)
}
if body.IsExpanded != nil {
db(req).ToggleFolderExpanded(id, *body.IsExpanded)
}
rw.WriteHeader(http.StatusOK)
} else if req.Method == "DELETE" {
db(req).DeleteFolder(id)
rw.WriteHeader(http.StatusNoContent)
}
}
func FeedRefreshHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "POST" {
handler(req).fetchAllFeeds()
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
}
func FeedIconHandler(rw http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
feed := db(req).GetFeed(id)
if feed != nil && feed.Icon != nil {
rw.Header().Set("Content-Type", http.DetectContentType(*feed.Icon))
rw.Header().Set("Content-Length", strconv.Itoa(len(*feed.Icon)))
rw.Write(*feed.Icon)
} else {
rw.WriteHeader(http.StatusNotFound)
}
}
func FeedListHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
list := db(req).ListFeeds()
writeJSON(rw, list)
} else if req.Method == "POST" {
var form FeedCreateForm
if err := json.NewDecoder(req.Body).Decode(&form); err != nil {
handler(req).log.Print(err)
rw.WriteHeader(http.StatusBadRequest)
return
}
feed, sources, err := discoverFeed(form.Url)
if err != nil {
handler(req).log.Print(err)
writeJSON(rw, map[string]string{"status": "notfound"})
return
}
if feed != nil {
storedFeed := db(req).CreateFeed(
feed.Title,
feed.Description,
feed.Link,
feed.FeedLink,
form.FolderID,
)
db(req).CreateItems(convertItems(feed.Items, *storedFeed))
writeJSON(rw, map[string]string{"status": "success"})
} else if sources != nil {
writeJSON(rw, map[string]interface{}{"status": "multiple", "choice": sources})
} else {
writeJSON(rw, map[string]string{"status": "notfound"})
}
}
}
func FeedHandler(rw http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
if req.Method == "PUT" {
feed := db(req).GetFeed(id)
if feed == nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
body := make(map[string]interface{})
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
handler(req).log.Print(err)
rw.WriteHeader(http.StatusBadRequest)
return
}
if title, ok := body["title"]; ok {
if reflect.TypeOf(title).Kind() == reflect.String {
db(req).RenameFeed(id, title.(string))
}
}
if f_id, ok := body["folder_id"]; ok {
if f_id == nil {
db(req).UpdateFeedFolder(id, nil)
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
folderId := int64(f_id.(float64))
db(req).UpdateFeedFolder(id, &folderId)
}
}
rw.WriteHeader(http.StatusOK)
} else if req.Method == "DELETE" {
db(req).DeleteFeed(id)
rw.WriteHeader(http.StatusNoContent)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
}
func ItemHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "PUT" {
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
var body ItemUpdateForm
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
handler(req).log.Print(err)
rw.WriteHeader(http.StatusBadRequest)
return
}
if body.Status != nil {
db(req).UpdateItemStatus(id, *body.Status)
}
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
}
func ItemListHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
perPage := 20
curPage := 1
query := req.URL.Query()
if page, err := strconv.ParseInt(query.Get("page"), 10, 64); err == nil {
curPage = int(page)
}
filter := storage.ItemFilter{}
if folderID, err := strconv.ParseInt(query.Get("folder_id"), 10, 64); err == nil {
filter.FolderID = &folderID
}
if feedID, err := strconv.ParseInt(query.Get("feed_id"), 10, 64); err == nil {
filter.FeedID = &feedID
}
if status := query.Get("status"); len(status) != 0 {
statusValue := storage.StatusValues[status]
filter.Status = &statusValue
}
if search := query.Get("search"); len(search) != 0 {
filter.Search = &search
}
newestFirst := query.Get("oldest_first") != "true"
items := db(req).ListItems(filter, (curPage-1)*perPage, perPage, newestFirst)
count := db(req).CountItems(filter)
writeJSON(rw, map[string]interface{}{
"page": map[string]int{
"cur": curPage,
"num": int(math.Ceil(float64(count) / float64(perPage))),
},
"list": items,
})
} else if req.Method == "PUT" {
query := req.URL.Query()
filter := storage.ItemFilter{}
if folderID, err := strconv.ParseInt(query.Get("folder_id"), 10, 64); err == nil {
filter.FolderID = &folderID
}
if feedID, err := strconv.ParseInt(query.Get("feed_id"), 10, 64); err == nil {
filter.FeedID = &feedID
}
db(req).MarkItemsRead(filter)
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
}
func SettingsHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
writeJSON(rw, db(req).GetSettings())
} else if req.Method == "PUT" {
settings := make(map[string]interface{})
if err := json.NewDecoder(req.Body).Decode(&settings); err != nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
if db(req).UpdateSettings(settings) {
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusBadRequest)
}
}
}
func OPMLImportHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "POST" {
file, _, err := req.FormFile("opml")
if err != nil {
handler(req).log.Print(err)
return
}
doc, err := parseOPML(file)
if err != nil {
handler(req).log.Print(err)
return
}
for _, outline := range doc.Outlines {
if outline.Type == "rss" {
db(req).CreateFeed(outline.Title, outline.Description, outline.SiteURL, outline.FeedURL, nil)
} else {
folder := db(req).CreateFolder(outline.Title)
for _, o := range outline.AllFeeds() {
db(req).CreateFeed(o.Title, o.Description, o.SiteURL, o.FeedURL, &folder.Id)
}
}
}
handler(req).fetchAllFeeds()
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
}
func OPMLExportHandler(rw http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
rw.Header().Set("Content-Type", "application/xml; charset=utf-8")
rw.Header().Set("Content-Disposition", `attachment; filename="subscriptions.opml"`)
builder := strings.Builder{}
line := func(s string, args ...string) {
if len(args) > 0 {
escapedargs := make([]interface{}, len(args))
for idx, arg := range args {
escapedargs[idx] = html.EscapeString(arg)
}
s = fmt.Sprintf(s, escapedargs...)
}
builder.WriteString(s)
builder.WriteString("\n")
}
feedline := func(feed storage.Feed, indent int) {
line(
strings.Repeat(" ", indent)+
`<outline type="rss" text="%s" description="%s" xmlUrl="%s" htmlUrl="%s"/>`,
feed.Title, feed.Description,
feed.FeedLink, feed.Link,
)
}
line(`<?xml version="1.0" encoding="UTF-8"?>`)
line(`<opml version="1.1">`)
line(`<head>`)
line(` <title>subscriptions.opml</title>`)
line(`</head>`)
line(`<body>`)
feedsByFolderID := make(map[int64][]storage.Feed)
for _, feed := range db(req).ListFeeds() {
var folderId = int64(0)
if feed.FolderId != nil {
folderId = *feed.FolderId
}
if feedsByFolderID[folderId] == nil {
feedsByFolderID[folderId] = make([]storage.Feed, 0)
}
feedsByFolderID[folderId] = append(feedsByFolderID[folderId], feed)
}
for _, folder := range db(req).ListFolders() {
line(` <outline text="%s">`, folder.Title)
for _, feed := range feedsByFolderID[folder.Id] {
feedline(feed, 4)
}
line(` </outline>`)
}
for _, feed := range feedsByFolderID[0] {
feedline(feed, 2)
}
line(`</body>`)
line(`</opml>`)
rw.Write([]byte(builder.String()))
}
}
func PageCrawlHandler(rw http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
if url := query.Get("url"); len(url) > 0 {
res, err := http.Get(url)
if err == nil {
body, err := ioutil.ReadAll(res.Body)
if err == nil {
rw.Write(body)
}
}
}
}

View File

@@ -1,42 +0,0 @@
package server
import (
"encoding/xml"
"io"
)
type opml struct {
XMLName xml.Name `xml:"opml"`
Version string `xml:"version,attr"`
Outlines []outline `xml:"body>outline"`
}
type outline struct {
Type string `xml:"type,attr,omitempty"`
Title string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Description string `xml:"description,attr,omitempty"`
Outlines []outline `xml:"outline,omitempty"`
}
func (o outline) AllFeeds() []outline {
result := make([]outline, 0)
for _, sub := range o.Outlines {
if sub.Type == "rss" {
result = append(result, sub)
} else {
result = append(result, sub.AllFeeds()...)
}
}
return result
}
func parseOPML(r io.Reader) (*opml, error) {
feeds := new(opml)
decoder := xml.NewDecoder(r)
decoder.Entity = xml.HTMLEntity
decoder.Strict = false
err := decoder.Decode(&feeds)
return feeds, err
}

View File

@@ -1,17 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"log"
)
func writeJSON(rw http.ResponseWriter, data interface{}) {
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
reply, err := json.Marshal(data)
if err != nil {
log.Fatal(err)
}
rw.Write(reply)
rw.Write([]byte("\n"))
}

View File

@@ -1,42 +0,0 @@
package server
import (
"net/http"
"regexp"
)
type Route struct {
url string
urlRegex *regexp.Regexp
handler func(http.ResponseWriter, *http.Request)
}
func p(path string, handler func(http.ResponseWriter, *http.Request)) Route {
var urlRegexp string
urlRegexp = regexp.MustCompile(`[\*\:]\w+`).ReplaceAllStringFunc(path, func(m string) string {
if m[0:1] == `*` {
return "(?P<" + m[1:] + ">.+)"
}
return "(?P<" + m[1:] + ">[^/]+)"
})
urlRegexp = "^" + urlRegexp + "$"
return Route{
url: path,
urlRegex: regexp.MustCompile(urlRegexp),
handler: handler,
}
}
func getRoute(req *http.Request) (*Route, map[string]string) {
vars := make(map[string]string)
for _, route := range routes {
if route.urlRegex.MatchString(req.URL.Path) {
matches := route.urlRegex.FindStringSubmatchIndex(req.URL.Path)
for i, key := range route.urlRegex.SubexpNames()[1:] {
vars[key] = req.URL.Path[matches[i*2+2]:matches[i*2+3]]
}
return &route, vars
}
}
return nil, nil
}

View File

@@ -1,139 +0,0 @@
package server
import (
"context"
"github.com/nkanaev/yarr/storage"
"log"
"net/http"
"runtime"
"sync/atomic"
"time"
)
type Handler struct {
Addr string
db *storage.Storage
log *log.Logger
feedQueue chan storage.Feed
queueSize *int32
}
func New(db *storage.Storage, logger *log.Logger, addr string) *Handler {
queueSize := int32(0)
return &Handler{
db: db,
log: logger,
feedQueue: make(chan storage.Feed, 3000),
queueSize: &queueSize,
Addr: addr,
}
}
func (h *Handler) Start() {
h.startJobs()
s := &http.Server{Addr: h.Addr, Handler: h}
s.ListenAndServe()
}
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
route, vars := getRoute(req)
if route == nil {
rw.WriteHeader(http.StatusNotFound)
return
}
ctx := context.WithValue(req.Context(), ctxHandler, &h)
ctx = context.WithValue(ctx, ctxVars, vars)
route.handler(rw, req.WithContext(ctx))
}
func (h *Handler) startJobs() {
delTicker := time.NewTicker(time.Hour * 24)
syncSearchChannel := make(chan bool, 10)
var syncSearchTimer *time.Timer // TODO: should this be atomic?
syncSearch := func() {
if syncSearchTimer == nil {
syncSearchTimer = time.AfterFunc(time.Second * 2, func() {
syncSearchChannel <- true
})
} else {
syncSearchTimer.Reset(time.Second * 2)
}
}
worker := func() {
for {
select {
case feed := <-h.feedQueue:
items, err := listItems(feed)
atomic.AddInt32(h.queueSize, -1)
if err != nil {
h.log.Printf("Failed to fetch %s (%d): %s", feed.FeedLink, feed.Id, err)
continue
}
h.db.CreateItems(items)
syncSearch()
if !feed.HasIcon {
h.log.Println("searching favicon for:", feed.Link, feed.FeedLink)
icon, err := findFavicon(feed.Link, feed.FeedLink)
if icon != nil {
h.db.UpdateFeedIcon(feed.Id, icon)
}
if err != nil {
h.log.Print(err)
}
}
case <- delTicker.C:
h.db.DeleteOldItems()
case <- syncSearchChannel:
h.db.SyncSearch()
}
}
}
num := runtime.NumCPU() - 1
if num < 1 {
num = 1
}
for i := 0; i < num; i++ {
go worker()
}
go h.db.DeleteOldItems()
go h.db.SyncSearch()
//h.fetchAllFeeds()
}
func (h *Handler) fetchAllFeeds() {
for _, feed := range h.db.ListFeeds() {
h.fetchFeed(feed)
}
}
func (h *Handler) fetchFeed(feed storage.Feed) {
atomic.AddInt32(h.queueSize, 1)
h.feedQueue <- feed
}
func Vars(req *http.Request) map[string]string {
if rv := req.Context().Value(ctxVars); rv != nil {
return rv.(map[string]string)
}
return nil
}
func db(req *http.Request) *storage.Storage {
if h := handler(req); h != nil {
return h.db
}
return nil
}
func handler(req *http.Request) *Handler {
return req.Context().Value(ctxHandler).(*Handler)
}
const (
ctxVars = 2
ctxHandler = 3
)

62
src/assets/assets.go Normal file
View File

@@ -0,0 +1,62 @@
package assets
import (
"embed"
"html/template"
"io"
"io/fs"
"io/ioutil"
"log"
"os"
)
type assetsfs struct {
embedded *embed.FS
templates map[string]*template.Template
}
var FS assetsfs
func (afs assetsfs) Open(name string) (fs.File, error) {
if afs.embedded != nil {
return afs.embedded.Open(name)
}
return os.DirFS("src/assets").Open(name)
}
func Template(path string) *template.Template {
var tmpl *template.Template
tmpl, found := FS.templates[path]
if !found {
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
"inline": func(svg string) template.HTML {
svgfile, err := FS.Open("graphicarts/" + svg)
// should never happen
if err != nil {
log.Fatal(err)
}
defer svgfile.Close()
content, err := ioutil.ReadAll(svgfile)
// should never happen
if err != nil {
log.Fatal(err)
}
return template.HTML(content)
},
}).ParseFS(FS, path))
if FS.embedded != nil {
FS.templates[path] = tmpl
}
}
return tmpl
}
func Render(path string, writer io.Writer, data interface{}) {
tmpl := Template(path)
tmpl.Execute(writer, data)
}
func init() {
FS.templates = make(map[string]*template.Template)
}

16
src/assets/assetsfs.go Normal file
View File

@@ -0,0 +1,16 @@
//go:build release
// +build release
package assets
import "embed"
//go:embed *.html
//go:embed graphicarts
//go:embed javascripts
//go:embed stylesheets
var embedded embed.FS
func init() {
FS.embedded = &embedded
}

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-vertical"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 356 B

View File

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 345 B

View File

Before

Width:  |  Height:  |  Size: 353 B

After

Width:  |  Height:  |  Size: 353 B

View File

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

View File

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg>

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 270 B

View File

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 270 B

View File

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

View File

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

View File

Before

Width:  |  Height:  |  Size: 370 B

After

Width:  |  Height:  |  Size: 370 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-anchor-favicon">
<circle cx="12" cy="5" r="3" stroke-width="4" stroke="#ffffff"></circle>
<line x1="12" y1="22" x2="12" y2="8" stroke-width="4" stroke="#ffffff"></line>
<path d="M5 12H2a10 10 0 0 0 20 0h-3" stroke-width="4" stroke="#ffffff"></path>
<circle cx="12" cy="5" r="3"></circle>
<line x1="12" y1="22" x2="12" y2="8"></line>
<path d="M5 12H2a10 10 0 0 0 20 0h-3"></path>
</svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder-minus"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path><line x1="9" y1="14" x2="15" y2="14"></line></svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder-plus"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path><line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line></svg>

After

Width:  |  Height:  |  Size: 405 B

View File

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>

After

Width:  |  Height:  |  Size: 367 B

View File

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 343 B

View File

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 304 B

View File

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File

Before

Width:  |  Height:  |  Size: 330 B

After

Width:  |  Height:  |  Size: 330 B

View File

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 308 B

View File

Before

Width:  |  Height:  |  Size: 611 B

After

Width:  |  Height:  |  Size: 611 B

View File

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 348 B

View File

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

417
src/assets/index.html Normal file
View File

@@ -0,0 +1,417 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
<link rel="manifest" href="./manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<script>
window.app = window.app || {}
window.app.settings = {% .settings %}
window.app.authenticated = {% .authenticated %}
</script>
</head>
<body class="theme-{% .settings.theme_name %}">
<div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak>
<!-- feed list -->
<div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
<drag :width="feedListWidth" @resize="resizeFeedList"></drag>
<div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div>
<button class="toolbar-item"
:class="{active: filterSelected == 'unread'}"
title="Unread"
@click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == 'starred'}"
title="Starred"
@click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == ''}"
title="All"
@click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span>
</button>
<div class="flex-grow-1"></div>
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<button class="dropdown-item" @click="showSettings('create')">
<span class="icon mr-1">{% inline "plus.svg" %}</span>
New Feed
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="fetchAllFeeds()">
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Theme</header>
<div class="row text-center m-0">
<button class="btn btn-link col-4 px-0 rounded-0"
:class="'theme-'+t"
@click.stop="theme.name = t"
v-for="t in ['light', 'sepia', 'night']">
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Auto Refresh</header>
<div class="row text-center m-0">
<button class="dropdown-item col-4 px-0" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Show first</header>
<div class="d-flex text-center">
<button class="dropdown-item px-0" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
<button class="dropdown-item px-0" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Subscriptions</header>
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
<input type="file"
id="opml-import"
@change="importOPML"
name="opml"
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
<span class="icon mr-1">{% inline "download.svg" %}</span>
Import
</label>
</form>
<a class="dropdown-item" href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span>
Export
</a>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="showSettings('shortcuts')">
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
Shortcuts
</button>
<div class="dropdown-divider" v-if="authenticated"></div>
<button class="dropdown-item" v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
</button>
</dropdown>
</div>
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
<label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2">{% inline "layers.svg" %}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
<span class="counter text-right">{{ filteredTotalStats }}</span>
</div>
</label>
<div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1"
:class="{'d-none': filterSelected
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
&& !filteredFolderStats[folder.id]
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2"
:class="{expanded: folder.is_expanded}"
@click.prevent="toggleFolderExpanded(folder)">
{% inline "chevron-right.svg" %}
</span>
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
<span class="counter text-right">{{ filteredFolderStats[folder.id] || '' }}</span>
</div>
</label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup"
:class="{'d-none': filterSelected
&& !(current.feed.id == feed.id)
&& !filteredFeedStats[feed.id]
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img :src="'./api/feeds/'+feed.id+'/icon'" alt="" loading="lazy"></span>
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
<span class="icon flex-shrink-0 mx-2"
:title="feed_errors[feed.id]"
v-if="!filterSelected && feed_errors[feed.id]">
{% inline "alert-circle.svg" %}
</span>
</div>
</label>
</div>
</div>
</div>
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
<span class="icon loading mx-2"></span>
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
</div>
</div>
<!-- item list -->
<div id="col-item-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
<drag :width="itemListWidth" @resize="resizeItemList"></drag>
<div class="px-2 toolbar d-flex align-items-center">
<button class="toolbar-item mr-2 d-block d-md-none"
@click="feedSelected = null"
title="Show Feeds">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<div class="input-icon flex-grow-1">
<span class="icon">{% inline "search.svg" %}</span>
<!-- id used by keybindings -->
<input id="searchbar" type="" class="d-block toolbar-search" v-model="itemSearch" @keydown.enter="$event.target.blur()">
</div>
<button class="toolbar-item ml-2"
@click="markItemsRead()"
v-if="filterSelected == 'unread'"
title="Mark All Read">
<span class="icon">{% inline "check.svg" %}</span>
</button>
<button class="btn btn-link toolbar-item px-2 ml-2" v-if="!current.type" disabled>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</button>
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
drop="right"
title="Feed Settings"
v-if="current.type == 'feed'">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<header class="dropdown-header">{{ current.feed.title }}</header>
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link">
<span class="icon mr-1">{% inline "globe.svg" %}</span>
Website
</a>
<a class="dropdown-item" :href="current.feed.feed_link" target="_blank" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "rss.svg" %}</span>
Feed Link
</a>
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
<button class="dropdown-item" @click="renameFeed(current.feed)">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Move to...</header>
<button class="dropdown-item"
v-if="folder.id != current.feed.folder_id"
v-for="folder in folders"
@click="moveFeed(current.feed, folder)">
<span class="icon mr-1">{% inline "folder.svg" %}</span>
{{ folder.title }}
</button>
<button class="dropdown-item text-muted" @click="moveFeed(current.feed, null)" v-if="current.feed.folder_id">
<span class="icon mr-1">{% inline "folder-minus.svg" %}</span>
──
</button>
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
new folder
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
<span class="icon mr-1">{% inline "trash.svg" %}</span>
Delete
</button>
</dropdown>
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
title="Folder Settings"
drop="right"
v-if="current.type == 'folder'">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<header class="dropdown-header">{{ current.folder.title }}</header>
<button class="dropdown-item" @click="renameFolder(current.folder)">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
<span class="icon mr-1">{% inline "trash.svg" %}</span>
Delete
</button>
</dropdown>
</div>
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id"
class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
<div class="selectgroup-label d-flex flex-column">
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<transition name="indicator">
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition>
<small class="flex-fill text-truncate mr-1">
{{ (feedsById[item.feed_id] || {}).title }}
</small>
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
</div>
<div>{{ item.title || 'untitled' }}</div>
</div>
</label>
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
</div>
<div class="px-3 py-2 border-top text-danger text-break" v-if="feed_errors[current.feed.id]">
{{ feed_errors[current.feed.id] }}
</div>
</div>
<!-- item show -->
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
<button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)"
title="Mark Starred">
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
</button>
<button class="toolbar-item"
title="Mark Unread"
@click="toggleItemRead(itemSelectedDetails)">
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
</button>
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
<template v-slot:button>
<span class="icon">{% inline "sliders.svg" %}</span>
</template>
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">monospace</button>
<div class="d-flex text-center">
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
<button class="dropdown-item" style="font-size: 1.2rem" @click.stop="incrFont(1)">A</button>
</div>
</dropdown>
<button class="toolbar-item"
:class="{active: itemSelectedReadability}"
@click="toggleReadability()"
title="Read Here">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button>
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" title="Open Link">
<span class="icon">{% inline "external-link.svg" %}</span>
</a>
<div class="flex-grow-1"></div>
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
<span class="icon">{% inline "x.svg" %}</span>
</button>
</div>
<div v-if="itemSelectedDetails"
ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto"
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
:style="{'font-size': theme.size + 'rem'}">
<div class="content-wrapper">
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
<div class="text-muted">
<div>{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}</div>
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
</div>
<hr>
<div v-if="!itemSelectedReadability">
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
</div>
<div v-html="itemSelectedContent"></div>
</div>
</div>
</div>
<modal :open="!!settings" @hide="settings = ''">
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="settings = ''">
<span class="icon">{% inline "x.svg" %}</span>
</button>
<div v-if="settings=='create'">
<p class="cursor-default"><b>New Feed</b></p>
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
<label for="feed-url">URL</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
<label for="feed-folder" class="mt-3 d-block">
Folder
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
</label>
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
<option value="">---</option>
<option :value="folder.id" v-for="folder in folders" :selected="folder.id === current.feed.folder_id || folder.id === current.folder.id">{{ folder.title }}</option>
</select>
<div class="mt-4" v-if="feedNewChoice.length">
<p class="mb-2">
Multiple feeds found. Choose one below:
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
</p>
<label class="selectgroup" v-for="choice in feedNewChoice">
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
<div class="selectgroup-label">
<div class="text-truncate">{{ choice.title }}</div>
<div class="text-truncate" :class="{light: choice.title}">{{ choice.url }}</div>
</div>
</label>
</div>
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
</form>
</div>
<div v-else-if="settings=='shortcuts'">
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
<table class="table table-borderless table-sm table-compact m-0">
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
<td>show unread / starred / all feeds</td></tr>
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
</tr>
</table>
</div>
</modal>
</div>
<!-- external -->
<script src="./static/javascripts/vue.min.js"></script>
<!-- internal -->
<script src="./static/javascripts/api.js"></script>
<script src="./static/javascripts/app.js"></script>
<script src="./static/javascripts/key.js"></script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
"use strict";
(function() {
var xfetch = function(resource, init) {
init = init || {}
if (['post', 'put', 'delete'].indexOf(init.method) !== -1) {
init['headers'] = init['headers'] || {}
init['headers']['x-requested-by'] = 'yarr'
}
return fetch(resource, init)
}
var api = function(method, endpoint, data) {
var headers = {'Content-Type': 'application/json'}
return xfetch(endpoint, {
method: method,
headers: headers,
body: JSON.stringify(data),
})
}
var json = function(res) {
return res.json()
}
var param = function(query) {
if (!query) return ''
return '?' + Object.keys(query).map(function(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(query[key])
}).join('&')
}
window.api = {
feeds: {
list: function() {
return api('get', './api/feeds').then(json)
},
create: function(data) {
return api('post', './api/feeds', data).then(json)
},
update: function(id, data) {
return api('put', './api/feeds/' + id, data)
},
delete: function(id) {
return api('delete', './api/feeds/' + id)
},
list_items: function(id) {
return api('get', './api/feeds/' + id + '/items').then(json)
},
refresh: function() {
return api('post', './api/feeds/refresh')
},
list_errors: function() {
return api('get', './api/feeds/errors').then(json)
},
},
folders: {
list: function() {
return api('get', './api/folders').then(json)
},
create: function(data) {
return api('post', './api/folders', data).then(json)
},
update: function(id, data) {
return api('put', './api/folders/' + id, data)
},
delete: function(id) {
return api('delete', './api/folders/' + id)
},
list_items: function(id) {
return api('get', './api/folders/' + id + '/items').then(json)
}
},
items: {
get: function(id) {
return api('get', './api/items/' + id).then(json)
},
list: function(query) {
return api('get', './api/items' + param(query)).then(json)
},
update: function(id, data) {
return api('put', './api/items/' + id, data)
},
mark_read: function(query) {
return api('put', './api/items' + param(query))
},
},
settings: {
get: function() {
return api('get', './api/settings').then(json)
},
update: function(data) {
return api('put', './api/settings', data)
},
},
status: function() {
return api('get', './api/status').then(json)
},
upload_opml: function(form) {
return xfetch('./opml/import', {
method: 'post',
body: new FormData(form),
})
},
logout: function() {
return api('post', './logout')
},
crawl: function(url) {
return api('get', './page?url=' + encodeURIComponent(url)).then(json)
}
}
})()

View File

@@ -2,14 +2,6 @@
var TITLE = document.title
var FONTS = [
"Arial",
"Courier New",
"Georgia",
"Times New Roman",
"Verdana",
]
var debounce = function(callback, wait) {
var timeout
return function() {
@@ -21,30 +13,6 @@ var debounce = function(callback, wait) {
}
}
var sanitize = function(content, base) {
// WILD: `item.link` may be a relative link (or some nonsense)
try { new URL(base) } catch(err) { base = null }
var sanitizer = new DOMPurify
sanitizer.addHook('afterSanitizeAttributes', function(node) {
// set all elements owning target to target=_blank
if ('target' in node)
node.setAttribute('target', '_blank')
// set non-HTML/MathML links to xlink:show=new
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href')))
node.setAttribute('xlink:show', 'new')
// set absolute urls
if (base && node.attributes.href && node.attributes.href.value)
node.href = new URL(node.attributes.href.value, base).toString()
if (base && node.attributes.src && node.attributes.src.value)
node.src = new URL(node.attributes.src.value, base).toString()
})
return sanitizer.sanitize(content, {FORBID_TAGS: ['style'], FORBID_ATTR: ['style', 'class']})
}
Vue.use(VueLazyload)
Vue.directive('scroll', {
inserted: function(el, binding) {
el.addEventListener('scroll', debounce(function(event) {
@@ -53,6 +21,12 @@ Vue.directive('scroll', {
},
})
Vue.directive('focus', {
inserted: function(el) {
el.focus()
}
})
Vue.component('drag', {
props: ['width'],
template: '<div class="drag"></div>',
@@ -78,16 +52,113 @@ Vue.component('drag', {
},
})
Vue.component('dropdown', {
props: ['class', 'toggle-class', 'ref', 'drop', 'title'],
data: function() {
return {open: false}
},
template: `
<div class="dropdown" :class="$attrs.class">
<button ref="btn" @click="toggle" :class="btnToggleClass" :title="$props.title"><slot name="button"></slot></button>
<div ref="menu" class="dropdown-menu" :class="{show: open}"><slot v-if="open"></slot></div>
</div>
`,
computed: {
btnToggleClass: function() {
var c = this.$props.toggleClass || ''
c += ' dropdown-toggle dropdown-toggle-no-caret'
c += this.open ? ' show' : ''
return c.trim()
}
},
methods: {
toggle: function(e) {
this.open ? this.hide() : this.show()
},
show: function(e) {
this.open = true
this.$refs.menu.style.top = this.$refs.btn.offsetHeight + 'px'
var drop = this.$props.drop
if (drop === 'right') {
this.$refs.menu.style.left = 'auto'
this.$refs.menu.style.right = '0'
} else if (drop === 'center') {
this.$nextTick(function() {
var btnWidth = this.$refs.btn.getBoundingClientRect().width
var menuWidth = this.$refs.menu.getBoundingClientRect().width
this.$refs.menu.style.left = '-' + ((menuWidth - btnWidth) / 2) + 'px'
}.bind(this))
}
document.addEventListener('click', this.clickHandler)
},
hide: function() {
this.open = false
document.removeEventListener('click', this.clickHandler)
},
clickHandler: function(e) {
var dropdown = e.target.closest('.dropdown')
if (dropdown == null || dropdown != this.$el) return this.hide()
if (e.target.closest('.dropdown-item') != null) return this.hide()
}
},
})
Vue.component('modal', {
props: ['open'],
template: `
<div class="modal custom-modal" tabindex="-1" v-if="$props.open">
<div class="modal-dialog">
<div class="modal-content" ref="content">
<div class="modal-body">
<slot v-if="$props.open"></slot>
</div>
</div>
</div>
</div>
`,
data: function() {
return {opening: false}
},
watch: {
'open': function(newVal) {
if (newVal) {
this.opening = true
document.addEventListener('click', this.handleClick)
} else {
document.removeEventListener('click', this.handleClick)
}
},
},
methods: {
handleClick: function(e) {
if (this.opening) {
this.opening = false
return
}
if (e.target.closest('.modal-content') == null) this.$emit('hide')
},
},
})
function dateRepr(d) {
var sec = (new Date().getTime() - d.getTime()) / 1000
var neg = sec < 0
var out = ''
sec = Math.abs(sec)
if (sec < 2700) // less than 45 minutes
return Math.round(sec / 60) + 'm'
out = Math.round(sec / 60) + 'm'
else if (sec < 86400) // less than 24 hours
return Math.round(sec / 3600) + 'h'
out = Math.round(sec / 3600) + 'h'
else if (sec < 604800) // less than a week
return Math.round(sec / 86400) + 'd'
out = Math.round(sec / 86400) + 'd'
else
return d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
if (neg) return '-' + out
return out
}
Vue.component('relative-time', {
@@ -100,7 +171,7 @@ Vue.component('relative-time', {
'interval': null,
}
},
template: '<time :datetime="val">{{formatted}}</time>',
template: '<time :datetime="val">{{ formatted }}</time>',
mounted: function() {
this.interval = setInterval(function() {
this.formatted = dateRepr(this.date)
@@ -113,56 +184,54 @@ Vue.component('relative-time', {
var vm = new Vue({
created: function() {
this.refreshFeeds()
this.refreshStats()
},
mounted: function() {
this.$root.$on('bv::modal::hidden', function(bvEvent, modalId) {
if (vm.settings == 'create') {
vm.feedNewChoice = []
vm.feedNewChoiceSelected = ''
}
.then(this.refreshFeeds.bind(this))
.then(this.refreshItems.bind(this, false))
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
},
data: function() {
var s = app.settings
return {
'filterSelected': null,
'filterSelected': s.filter,
'folders': [],
'feeds': [],
'feedSelected': null,
'feedListWidth': null,
'feedSelected': s.feed,
'feedListWidth': s.feed_list_width || 300,
'feedNewChoice': [],
'feedNewChoiceSelected': '',
'items': [],
'itemsPage': {
'cur': 1,
'num': 1,
},
'itemsHasMore': true,
'itemSelected': null,
'itemSelectedDetails': {},
'itemSelectedDetails': null,
'itemSelectedReadability': '',
'itemSearch': '',
'itemSortNewestFirst': null,
'itemListWidth': null,
'itemSortNewestFirst': s.sort_newest_first,
'itemListWidth': s.item_list_width || 300,
'filteredFeedStats': {},
'filteredFolderStats': {},
'filteredTotalStats': null,
'settings': 'create',
'settings': '',
'loading': {
'feeds': 0,
'newfeed': false,
'items': false,
'readability': false,
},
'fonts': FONTS,
'fonts': ['', 'serif', 'monospace'],
'feedStats': {},
'theme': {
'name': 'light',
'font': '',
'size': 1,
'name': s.theme_name,
'font': s.theme_font,
'size': s.theme_size,
},
'refreshRate': s.refresh_rate,
'authenticated': app.authenticated,
'feed_errors': {},
}
},
computed: {
@@ -182,10 +251,24 @@ var vm = new Vue({
return folders
},
feedsById: function() {
return this.feeds.reduce(function(acc, feed) { acc[feed.id] = feed; return acc }, {})
return this.feeds.reduce(function(acc, f) { acc[f.id] = f; return acc }, {})
},
itemsById: function() {
return this.items.reduce(function(acc, item) { acc[item.id] = item; return acc }, {})
foldersById: function() {
return this.folders.reduce(function(acc, f) { acc[f.id] = f; return acc }, {})
},
current: function() {
var parts = (this.feedSelected || '').split(':', 2)
var type = parts[0]
var guid = parts[1]
var folder = {}, feed = {}
if (type == 'feed')
feed = this.feedsById[guid] || {}
if (type == 'folder')
folder = this.foldersById[guid] || {}
return {type: type, feed: feed, folder: folder}
},
itemSelectedContent: function() {
if (!this.itemSelected) return ''
@@ -193,13 +276,7 @@ var vm = new Vue({
if (this.itemSelectedReadability)
return this.itemSelectedReadability
var content = ''
if (this.itemSelectedDetails.content)
content = this.itemSelectedDetails.content
else if (this.itemSelectedDetails.description)
content = this.itemSelectedDetails.description
return sanitize(content, this.itemSelectedDetails.link)
return this.itemSelectedDetails.content || ''
},
},
watch: {
@@ -229,14 +306,14 @@ var vm = new Vue({
}, 500),
},
'filterSelected': function(newVal, oldVal) {
if (oldVal === null) return // do nothing, initial setup
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this))
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
this.itemSelected = null
this.computeStats()
},
'feedSelected': function(newVal, oldVal) {
if (oldVal === null) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
this.itemSelected = null
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
},
@@ -248,32 +325,41 @@ var vm = new Vue({
}
if (this.$refs.content) this.$refs.content.scrollTop = 0
this.itemSelectedDetails = this.itemsById[newVal]
if (this.itemSelectedDetails.status == 'unread') {
this.itemSelectedDetails.status = 'read'
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1
api.items.update(this.itemSelectedDetails.id, {status: this.itemSelectedDetails.status})
}
api.items.get(newVal).then(function(item) {
this.itemSelectedDetails = item
if (this.itemSelectedDetails.status == 'unread') {
api.items.update(this.itemSelectedDetails.id, {status: 'read'}).then(function() {
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1
var itemInList = this.items.find(function(i) { return i.id == item.id })
if (itemInList) itemInList.status = 'read'
this.itemSelectedDetails.status = 'read'
}.bind(this))
}
}.bind(this))
},
'itemSearch': debounce(function(newVal) {
this.refreshItems()
}, 500),
'itemSortNewestFirst': function(newVal, oldVal) {
if (oldVal === null) return
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this))
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({sort_newest_first: newVal}).then(vm.refreshItems.bind(this, false))
},
'feedListWidth': debounce(function(newVal, oldVal) {
if (oldVal === null) return
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed_list_width: newVal})
}, 1000),
'itemListWidth': debounce(function(newVal, oldVal) {
if (oldVal === null) return
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({item_list_width: newVal})
}, 1000),
'refreshRate': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({refresh_rate: newVal})
},
},
methods: {
refreshStats: function(loopMode) {
api.status().then(function(data) {
return api.status().then(function(data) {
if (loopMode && !vm.itemSelected) vm.refreshItems()
vm.loading.feeds = data.running
@@ -284,6 +370,10 @@ var vm = new Vue({
acc[stat.feed_id] = stat
return acc
}, {})
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
})
},
getItemsQuery: function() {
@@ -317,29 +407,49 @@ var vm = new Vue({
vm.feeds = values[1]
})
},
refreshItems: function() {
refreshItems: function(loadMore) {
if (this.feedSelected === null) {
vm.items = []
vm.itemsHasMore = false
return
}
var query = this.getItemsQuery()
if (loadMore) {
query.after = vm.items[vm.items.length-1].id
}
this.loading.items = true
api.items.list(query).then(function(data) {
vm.items = data.list
vm.itemsPage = data.page
if (loadMore) {
vm.items = vm.items.concat(data.list)
} else {
vm.items = data.list
}
vm.itemsHasMore = data.has_more
vm.loading.items = false
// load more if there's some space left at the bottom of the item list.
vm.$nextTick(function() {
if (vm.itemsHasMore && !vm.loading.items && vm.itemListCloseToBottom()) {
vm.refreshItems(true)
}
})
})
},
itemListCloseToBottom: function() {
// approx. vertical space at the bottom of the list (loading el & paddings) when 1rem = 16px
var bottomSpace = 70
var scale = (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) / 16
var el = this.$refs.itemlist
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < bottomSpace * scale
return closeToBottom
},
loadMoreItems: function(event, el) {
if (this.itemsPage.cur >= this.itemsPage.num) return
if (!this.itemsHasMore) return
if (this.loading.items) return
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
if (closeToBottom) {
this.loading.moreitems = true
var query = this.getItemsQuery()
query.page = this.itemsPage.cur + 1
api.items.list(query).then(function(data) {
vm.items = vm.items.concat(data.list)
vm.itemsPage = data.page
vm.loading.items = false
})
}
if (this.itemListCloseToBottom()) this.refreshItems(true)
},
markItemsRead: function() {
var query = this.getItemsQuery()
@@ -347,6 +457,7 @@ var vm = new Vue({
vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1}
vm.itemSelected = null
vm.itemsHasMore = false
vm.refreshStats()
})
},
@@ -365,6 +476,7 @@ var vm = new Vue({
var folder_id = folder ? folder.id : null
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {
feed.folder_id = folder_id
vm.refreshStats()
})
},
moveFeedToNewFolder: function(feed) {
@@ -372,7 +484,9 @@ var vm = new Vue({
if (!title) return
api.folders.create({'title': title}).then(function(folder) {
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
vm.refreshFeeds()
vm.refreshFeeds().then(function() {
vm.refreshStats()
})
})
})
},
@@ -394,16 +508,16 @@ var vm = new Vue({
if (newTitle) {
api.folders.update(folder.id, {title: newTitle}).then(function() {
folder.title = newTitle
})
this.folders.sort(function(a, b) {
return a.title.localeCompare(b.title)
})
}.bind(this))
}
},
deleteFolder: function(folder) {
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
api.folders.delete(folder.id).then(function() {
if (vm.feedSelected === 'folder:'+folder.id) {
vm.items = []
vm.feedSelected = ''
}
vm.feedSelected = null
vm.refreshStats()
vm.refreshFeeds()
})
@@ -420,10 +534,7 @@ var vm = new Vue({
deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
api.feeds.delete(feed.id).then(function() {
if (vm.feedSelected === 'feed:'+feed.id) {
vm.items = []
vm.feedSelected = ''
}
vm.feedSelected = null
vm.refreshStats()
vm.refreshFeeds()
})
@@ -443,7 +554,8 @@ var vm = new Vue({
if (result.status === 'success') {
vm.refreshFeeds()
vm.refreshStats()
vm.$bvModal.hide('settings-modal')
vm.settings = ''
vm.feedSelected = 'feed:' + result.feed.id
} else if (result.status === 'multiple') {
vm.feedNewChoice = result.choice
vm.feedNewChoiceSelected = result.choice[0].url
@@ -453,25 +565,30 @@ var vm = new Vue({
vm.loading.newfeed = false
})
},
toggleItemStatus: function(item, targetstatus, fallbackstatus) {
var oldstatus = item.status
var newstatus = item.status !== targetstatus ? targetstatus : fallbackstatus
var updateStats = function(status, incr) {
if ((status == 'unread') || (status == 'starred')) {
this.feedStats[item.feed_id][status] += incr
}
}.bind(this)
api.items.update(item.id, {status: newstatus}).then(function() {
updateStats(oldstatus, -1)
updateStats(newstatus, +1)
var itemInList = this.items.find(function(i) { return i.id == item.id })
if (itemInList) itemInList.status = newstatus
item.status = newstatus
}.bind(this))
},
toggleItemStarred: function(item) {
if (item.status == 'starred') {
item.status = 'read'
this.feedStats[item.feed_id].starred -= 1
} else if (item.status != 'starred') {
item.status = 'starred'
this.feedStats[item.feed_id].starred += 1
}
api.items.update(item.id, {status: item.status})
this.toggleItemStatus(item, 'starred', 'read')
},
toggleItemRead: function(item) {
if (item.status == 'unread') {
item.status = 'read'
this.feedStats[item.feed_id].unread -= 1
} else if (item.status == 'read') {
item.status = 'unread'
this.feedStats[item.feed_id].unread += 1
}
api.items.update(item.id, {status: item.status})
this.toggleItemStatus(item, 'unread', 'read')
},
importOPML: function(event) {
var input = event.target
@@ -483,28 +600,33 @@ var vm = new Vue({
vm.refreshStats()
})
},
getReadable: function(item) {
logout: function() {
api.logout().then(function() {
document.location.reload()
})
},
toggleReadability: function() {
if (this.itemSelectedReadability) {
this.itemSelectedReadability = null
return
}
var item = this.itemSelectedDetails
if (!item) return
if (item.link) {
this.loading.readability = true
api.crawl(item.link).then(function(body) {
api.crawl(item.link).then(function(data) {
vm.itemSelectedReadability = data && data.content
vm.loading.readability = false
if (!body.length) return
var bodyClean = sanitize(body, item.link)
var doc = new DOMParser().parseFromString(bodyClean, 'text/html')
var parsed = new Readability(doc).parse()
if (parsed && parsed.content) {
vm.itemSelectedReadability = parsed.content
}
})
}
},
showSettings: function(settings) {
this.settings = settings
this.$bvModal.show('settings-modal')
if (settings === 'create') {
vm.feedNewChoice = []
vm.feedNewChoiceSelected = ''
}
},
resizeFeedList: function(width) {
this.feedListWidth = Math.min(Math.max(200, width), 700)
@@ -520,7 +642,10 @@ var vm = new Vue({
this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1)
},
fetchAllFeeds: function() {
api.feeds.refresh().then(this.refreshStats.bind(this))
if (this.loading.feeds) return
api.feeds.refresh().then(function() {
vm.refreshStats()
})
},
computeStats: function() {
var filter = this.filterSelected
@@ -553,15 +678,4 @@ var vm = new Vue({
}
})
api.settings.get().then(function(data) {
vm.feedSelected = data.feed
vm.filterSelected = data.filter
vm.itemSortNewestFirst = data.sort_newest_first
vm.feedListWidth = data.feed_list_width || 300
vm.itemListWidth = data.item_list_width || 300
vm.theme.name = data.theme_name
vm.theme.font = data.theme_font
vm.theme.size = data.theme_size
vm.refreshItems()
vm.$mount('#app')
})
vm.$mount('#app')

View File

@@ -0,0 +1,208 @@
function scrollto(target, scroll) {
var padding = 10
var targetRect = target.getBoundingClientRect()
var scrollRect = scroll.getBoundingClientRect()
// target
var relativeOffset = targetRect.y - scrollRect.y
var absoluteOffset = relativeOffset + scroll.scrollTop
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
var newPos = scroll.scrollTop
if (relativeOffset < padding) {
newPos = absoluteOffset - padding
} else {
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
}
scroll.scrollTop = Math.round(newPos)
}
var helperFunctions = {
// navigation helper, navigate relative to selected item
navigateToItem: function(relativePosition) {
if (vm.itemSelected == null) {
// if no item is selected, select first
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
if (itemPosition === -1) {
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var newPosition = itemPosition + relativePosition
if (newPosition < 0 || newPosition >= vm.items.length) return
vm.itemSelected = vm.items[newPosition].id
vm.$nextTick(function() {
var scroll = document.querySelector('#item-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
})
},
// navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) {
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
.map(function(r) { return r.value })
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
if (currentFeedPosition == -1) {
vm.feedSelected = ''
return
}
var newPosition = currentFeedPosition+relativePosition
if (newPosition < 0 || newPosition >= navigationList.length) return
vm.feedSelected = navigationList[newPosition]
vm.$nextTick(function() {
var scroll = document.querySelector('#feed-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
})
},
scrollContent: function(direction) {
var padding = 40
var scroll = document.querySelector('.content')
if (!scroll) return
var height = scroll.getBoundingClientRect().height
var newpos = scroll.scrollTop + (height - padding) * direction
if (typeof scroll.scrollTo == 'function') {
scroll.scrollTo({top: newpos, left: 0, behavior: 'smooth'})
} else {
scroll.scrollTop = newpos
}
}
}
var shortcutFunctions = {
openItemLink: function() {
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
window.open(vm.itemSelectedDetails.link, '_blank')
}
},
toggleReadability: function() {
vm.toggleReadability()
},
toggleItemRead: function() {
if (vm.itemSelected != null) {
vm.toggleItemRead(vm.itemSelectedDetails)
}
},
markAllRead: function() {
// same condition as 'Mark all read button'
if (vm.filterSelected == 'unread'){
vm.markItemsRead()
}
},
toggleItemStarred: function() {
if (vm.itemSelected != null) {
vm.toggleItemStarred(vm.itemSelectedDetails)
}
},
focusSearch: function() {
document.getElementById("searchbar").focus()
},
nextItem(){
helperFunctions.navigateToItem(+1)
},
previousItem() {
helperFunctions.navigateToItem(-1)
},
nextFeed(){
helperFunctions.navigateToFeed(+1)
},
previousFeed() {
helperFunctions.navigateToFeed(-1)
},
scrollForward: function() {
helperFunctions.scrollContent(+1)
},
scrollBackward: function() {
helperFunctions.scrollContent(-1)
},
showAll() {
vm.filterSelected = ''
},
showUnread() {
vm.filterSelected = 'unread'
},
showStarred() {
vm.filterSelected = 'starred'
},
}
// If you edit, make sure you update the help modal
var keybindings = {
"o": shortcutFunctions.openItemLink,
"i": shortcutFunctions.toggleReadability,
"r": shortcutFunctions.toggleItemRead,
"R": shortcutFunctions.markAllRead,
"s": shortcutFunctions.toggleItemStarred,
"/": shortcutFunctions.focusSearch,
"j": shortcutFunctions.nextItem,
"k": shortcutFunctions.previousItem,
"l": shortcutFunctions.nextFeed,
"h": shortcutFunctions.previousFeed,
"f": shortcutFunctions.scrollForward,
"b": shortcutFunctions.scrollBackward,
"1": shortcutFunctions.showUnread,
"2": shortcutFunctions.showStarred,
"3": shortcutFunctions.showAll,
}
var codebindings = {
"KeyO": shortcutFunctions.openItemLink,
"KeyI": shortcutFunctions.toggleReadability,
//"r": shortcutFunctions.toggleItemRead,
//"KeyR": shortcutFunctions.markAllRead,
"KeyS": shortcutFunctions.toggleItemStarred,
"Slash": shortcutFunctions.focusSearch,
"KeyJ": shortcutFunctions.nextItem,
"KeyK": shortcutFunctions.previousItem,
"KeyL": shortcutFunctions.nextFeed,
"KeyH": shortcutFunctions.previousFeed,
"KeyF": shortcutFunctions.scrollForward,
"KeyB": shortcutFunctions.scrollBackward,
"Digit1": shortcutFunctions.showUnread,
"Digit2": shortcutFunctions.showStarred,
"Digit3": shortcutFunctions.showAll,
}
function isTextBox(element) {
var tagName = element.tagName.toLowerCase()
// Input elements that aren't text
var inputBlocklist = ['button','checkbox','color','file','hidden','image','radio','range','reset','search','submit']
return tagName === 'textarea' ||
( tagName === 'input'
&& inputBlocklist.indexOf(element.getAttribute('type').toLowerCase()) == -1
)
}
document.addEventListener('keydown',function(event) {
// Ignore while focused on text or
// when using modifier keys (to not clash with browser behaviour)
if (isTextBox(event.target) || event.metaKey || event.ctrlKey || event.altKey) {
return
}
var keybindFunction = keybindings[event.key] || codebindings[event.code]
if (keybindFunction) {
event.preventDefault()
keybindFunction()
}
})

43
src/assets/login.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
form {
max-width: 300px;
margin: 0 auto;
padding: 1rem;
}
form img {
width: 4rem;
height: 4rem;
display: block;
margin: 3rem auto;
}
</style>
</head>
<body>
<form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt="">
{% if .error %}
<div class="text-danger text-center my-3">{% .error %}</div>
{% end %}
<div class="form-group">
<label for="username">Username</label>
<input name="username" class="form-control" id="username" autocomplete="off"
value="{% if .username %}{% .username %}{% end %}" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input name="password" class="form-control" id="password" type="password" required>
</div>
<button class="btn btn-block btn-default" type="submit">Login</button>
</form>
</body>
</html>

View File

@@ -2,14 +2,12 @@
display: none !important;
}
body {
html {
font-size: 15px !important;
}
.wrapper {
max-width: 1440px;
margin: 0 auto;
overflow-x: hidden;
body {
overscroll-behavior: none;
}
/* bootstrap customizations */
@@ -18,8 +16,8 @@ body {
color: inherit;
}
.form-control {
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07);
.form-control, .form-control:focus {
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07) !important;
}
select.form-control {
@@ -28,12 +26,11 @@ select.form-control {
select.form-control:not([multiple]):not([size]) {
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.6rem .6rem;
padding-right: 1.2rem;
cursor: pointer;
}
.form-control:focus, .btn:focus {
.btn:focus {
box-shadow: none !important;
}
@@ -49,26 +46,25 @@ select.form-control:not([multiple]):not([size]) {
display: none;
}
#settings-modal {
color: #212529 !important;
}
.settings-dropdown .dropdown-toggle {
padding-left: 0;
padding-right: 0;
}
.dropdown-menu {
.settings-dropdown .dropdown-menu {
padding: 0;
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.07);
overflow: hidden;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
.dropdown-item, .dropdown-header {
.settings-dropdown .dropdown-item,
.settings-dropdown .dropdown-header {
padding: .375rem 1rem;
}
.dropdown-divider {
.settings-dropdown .dropdown-divider {
margin: 0;
}
@@ -76,28 +72,15 @@ select.form-control:not([multiple]):not([size]) {
outline: none;
}
.settings-dropdown .dropdown-item {
cursor: pointer;
}
.settings-dropdown .dropdown-item:focus {
outline: none;
}
.settings-dropdown.large .dropdown-item {
padding: .5rem 1rem;
}
.dropdown-danger .dropdown-item {
color: #dc3545!important;
}
.modal-backdrop {
background-color: rgba(0, 0, 0, 0.7);
}
.modal.fade .modal-dialog {
transition: none !important;
transform: none !important;
}
.b-dropdown-form:focus {
.settings-dropdown form:focus {
outline: none;
}
@@ -105,17 +88,28 @@ select.form-control:not([multiple]):not([size]) {
outline: none;
}
.b-tooltip {
opacity: 1;
font-size: .7rem;
.table-compact {
color: unset !important;
}
.b-tooltip:focus {
outline: none;
.table-compact tr td:first-child {
padding-left: 0;
}
.table-compact tr td:last-child {
padding-right: 0;
}
/* custom elements */
.font-serif {
font-family: Georgia, serif;
}
.font-monospace {
font-family: SFMono-Regular, Menlo, Consolas, monospace;
}
.icon {
height: 1rem;
width: 1rem;
@@ -173,7 +167,9 @@ select.form-control:not([multiple]):not([size]) {
opacity: 0;
position: absolute;
z-index: -1;
top: 0; left: 0;
top: 0;
left: 0;
height: 100%;
}
.selectgroup + .selectgroup {
@@ -183,13 +179,13 @@ select.form-control:not([multiple]):not([size]) {
.selectgroup-label {
padding: .375rem .5rem;
border-radius: 4px;
overflow-wrap: break-word;
}
.selectgroup-label:hover {
cursor: pointer;
}
.list-row:hover,
.toolbar-item:hover,
.toolbar-search:hover,
.selectgroup-label:hover,
@@ -245,7 +241,6 @@ select.form-control:not([multiple]):not([size]) {
border: 1px solid #ced4da;
border-radius: .25rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
background: linear-gradient(#fff, #f5f7f9);
}
.btn-default:active {
@@ -262,16 +257,6 @@ select.form-control:not([multiple]):not([size]) {
background-color: #f8f9fa;
}
.list-row {
padding-left: .5rem;
padding-right: .5rem;
margin-left: -.5rem;
margin-right: -.5rem;
border-radius: 3px;
user-select: none;
cursor: default;
}
.toolbar {
min-height: 2rem !important;
max-height: 2rem !important;
@@ -354,70 +339,70 @@ select.form-control:not([multiple]):not([size]) {
outline: none;
}
.themepicker {
position: relative;
background: none;
border: none;
width: 100%;
margin-bottom: 0;
}
.themepicker input {
opacity: 0;
position: absolute;
z-index: -1;
top: 0; left: 0;
}
.themepicker-label {
height: 1.75rem;
border-radius: 4px;
cursor: pointer;
}
.themepicker input[value=light] + .themepicker-label {
box-shadow: inset 0 0 0px 1px #dee2e6;
background: #fff;
}
.themepicker + .themepicker {
margin-left: .5rem;
}
.themepicker-label:hover {
box-shadow: inset 0 0 0 2px rgb(1, 123, 254, .6) !important;
}
.themepicker input:checked + .themepicker-label {
box-shadow: inset 0 0 0px 2px #017bfe !important;
}
.appearance-option {
height: 2rem;
padding-top: 0 !important;
padding-bottom: 0 !important;
line-height: 2rem;
}
#opml-import-form input[type="file"]::-webkit-file-upload-button {
position: absolute;
top: -999px;
left: -999px;
}
.custom-modal {
background-color: rgba(0, 0, 0, 0.9);
overflow-y: auto;
display: block !important;
}
/* content */
.content {
overflow-wrap: break-word;
line-height: 1.5;
}
.content-wrapper {
max-width: 60rem;
margin: 0 auto;
}
.content img, .content video {
max-width: 100%;
height: auto;
}
.content iframe {
display: block;
max-width: 100%;
margin-bottom: 0.5rem;
}
.content .video-wrapper {
position: relative;
display: block;
width: 100%;
overflow: hidden;
}
.content .video-wrapper::before {
display: block;
padding-top: 56.25%; /* 16x9 aspect ratio */
content: "";
}
.content .video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.content pre {
overflow-x: auto;
color: inherit;
border: 1px solid #dee2e6;
border-radius: 3px;
margin-left: -0.5rem;
margin-right: -0.5rem;
padding: 0.5rem;
}
.content a {
@@ -430,10 +415,6 @@ select.form-control:not([multiple]):not([size]) {
padding-left: 1rem;
}
.content pre {
overflow-x: scroll;
}
.content h1 {
font-size: 1.8rem;
}
@@ -449,14 +430,23 @@ select.form-control:not([multiple]):not([size]) {
font-size: 1rem;
}
.content p {
margin-top: 1rem;
margin-bottom: 1rem;
}
/* theme: light */
button.theme-light {
background-color: #fff !important;
}
a,
.btn-link:hover,
.toolbar-item.active {
.btn-link:hover {
color: #0080d4;
}
.toolbar-item.active,
.dropdown-item.active,
.dropdown-item:active,
.selectgroup input:checked + .selectgroup-label {
@@ -471,45 +461,60 @@ a,
/* theme: sepia */
.themepicker input[value=sepia] + .themepicker-label,
.theme-sepia,
.theme-sepia .btn-default,
.theme-sepia .dropdown-menu,
.theme-sepia .form-control,
.theme-sepia .modal-content,
.theme-sepia .toolbar-search {
background-color: #f4f0e5;
background-color: #f4f0e5 !important;
}
.theme-sepia .content hr,
.theme-sepia .content pre,
.theme-sepia .border-right,
.theme-sepia .border-top {
border-color: #e0d6ba !important;
}
.theme-sepia .selectgroup-label:not(.appearance-option):hover,
.theme-sepia .selectgroup-label:hover,
.theme-sepia .toolbar-item:hover,
.theme-sepia .toolbar-search:hover,
.theme-sepia .dropdown-item:hover,
.theme-sepia .toolbar-search:focus {
background-color: #e0d6ba;
}
/* theme: night */
.themepicker input[value=night] + .themepicker-label,
.theme-night,
.theme-night .btn-default,
.theme-night .dropdown-menu,
.theme-night .dropdown-item,
.theme-night .form-control,
.theme-night .modal-content,
.theme-night .toolbar-search {
color: #d1d1d1;
background-color: #0e0e0e;
}
.theme-night .content hr,
.theme-night .content pre,
.theme-night .border-right,
.theme-night .border-top {
.theme-night .border-top,
.theme-night .dropdown-divider {
border-color: #1a1a1a !important;
}
.theme-night .selectgroup-label:not(.appearance-option):hover,
.theme-night .selectgroup-label:hover,
.theme-night .dropdown-item:hover,
.theme-night .toolbar-item:hover,
.theme-night .toolbar-search:hover,
.theme-night .toolbar-search:focus {
background-color: #1a1a1a;
}
.theme-night .dropdown-menu,
.theme-night .modal-content {
border-color: #1a1a1a;
}
/* animation */
.indicator-enter-active, .indicator-leave-active {
transition: all .3s;
@@ -519,3 +524,87 @@ a,
opacity: 0;
margin: 0 !important;
}
/* responsive layout
tablet:
none selected: show feed list & item list
feed selected: show feed list & item list
item selected: show item
mobile:
none selected: show feed list
feed selected: show item list
item selected: show item
*/
@media (min-width: 768px) and (max-width: 991.98px) {
#app #col-feed-list {
width: 35% !important;
}
#app #col-item-list {
width: 65% !important;
border-right-width: 0 !important;
}
#app #col-item {
display: none !important;
}
#app.item-selected #col-feed-list {
display: none !important;
}
#app.item-selected #col-item-list {
display: none !important;
}
#app.item-selected #col-item {
display: flex !important;
}
}
@media (max-width: 767.98px) {
#app #col-feed-list {
width: 100% !important;
border-right-width: 0 !important;
}
#app #col-item-list {
width: 100% !important;
display: none !important;
border-right-width: 0 !important;
}
#app #col-item {
width: 100% !important;
display: none !important;
}
#app.feed-selected #col-feed-list {
display: none !important;
}
#app.feed-selected #col-item-list {
display: flex !important;
}
#app.item-selected #col-feed-list {
display: none !important;
}
#app.item-selected #col-item-list {
display: none !important;
}
#app.item-selected #col-item {
display: flex !important;
}
}
/* styles for both mobile & tablet layout */
@media (max-width: 991.98px) {
.drag {
cursor: default;
}
.toolbar {
min-height: 3rem !important;
max-height: 3rem !important;
}
.toolbar-item,
.toolbar-search {
padding: .5rem;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,86 @@
package htmlutil
import (
"regexp"
"strings"
"golang.org/x/net/html"
)
var nodeNameRegex = regexp.MustCompile(`\w+|\*`)
func FindNodes(node *html.Node, match func(*html.Node) bool) []*html.Node {
nodes := make([]*html.Node, 0)
queue := make([]*html.Node, 0)
queue = append(queue, node)
for len(queue) > 0 {
var n *html.Node
n, queue = queue[0], queue[1:]
if match(n) {
nodes = append(nodes, n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
queue = append(queue, c)
}
}
return nodes
}
func Query(node *html.Node, sel string) []*html.Node {
matcher := NewMatcher(sel)
return FindNodes(node, matcher.Match)
}
func Closest(node *html.Node, sel string) *html.Node {
matcher := NewMatcher(sel)
for cur := node; cur != nil; cur = cur.Parent {
if matcher.Match(cur) {
return cur
}
}
return nil
}
func NewMatcher(sel string) Matcher {
multi := MultiMatch{}
parts := strings.Split(sel, ",")
for _, part := range parts {
part := strings.TrimSpace(part)
if nodeNameRegex.MatchString(part) {
multi.Add(ElementMatch{Name: part})
} else {
panic("unsupported selector: " + part)
}
}
return multi
}
type Matcher interface {
Match(*html.Node) bool
}
type ElementMatch struct {
Name string
}
func (m ElementMatch) Match(n *html.Node) bool {
return n.Type == html.ElementNode && (n.Data == m.Name || m.Name == "*")
}
type MultiMatch struct {
matchers []Matcher
}
func (m *MultiMatch) Add(matcher Matcher) {
m.matchers = append(m.matchers, matcher)
}
func (m MultiMatch) Match(n *html.Node) bool {
for _, matcher := range m.matchers {
if matcher.Match(n) {
return true
}
}
return false
}

View File

@@ -0,0 +1,87 @@
package htmlutil
import (
"strings"
"testing"
"golang.org/x/net/html"
)
func TestQuery(t *testing.T) {
node, _ := html.Parse(strings.NewReader(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<div>
<p>test</p>
</div>
</body>
</html>
`))
nodes := Query(node, "p")
match := (len(nodes) == 1 &&
nodes[0].Type == html.ElementNode &&
nodes[0].Data == "p")
if !match {
t.Fatalf("incorrect match: %#v", nodes)
}
}
func TestQueryMulti(t *testing.T) {
node, _ := html.Parse(strings.NewReader(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<p>foo</p>
<div>
<p>bar</p>
<span>baz</span>
</div>
</body>
</html>
`))
nodes := Query(node, "p , span")
match := (len(nodes) == 3 &&
nodes[0].Type == html.ElementNode && nodes[0].Data == "p" &&
nodes[1].Type == html.ElementNode && nodes[1].Data == "p" &&
nodes[2].Type == html.ElementNode && nodes[2].Data == "span")
if !match {
for i, n := range nodes {
t.Logf("%d: %s", i, HTML(n))
}
t.Fatal("incorrect match")
}
}
func TestClosest(t *testing.T) {
html, _ := html.Parse(strings.NewReader(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<div class="foo">
<p><a class="bar" href=""></a></p>
</div>
</body>
</html>
`))
link := Query(html, "a")
if link == nil || Attr(link[0], "class") != "bar" {
t.FailNow()
}
wrap := Closest(link[0], "div")
if wrap == nil || Attr(wrap, "class") != "foo" {
t.FailNow()
}
}

View File

@@ -0,0 +1,33 @@
package htmlutil
import (
"net/url"
)
func Any(els []string, el string, match func(string, string) bool) bool {
for _, x := range els {
if match(x, el) {
return true
}
}
return false
}
func AbsoluteUrl(href, base string) string {
baseUrl, err := url.Parse(base)
if err != nil {
return ""
}
hrefUrl, err := url.Parse(href)
if err != nil {
return ""
}
return baseUrl.ResolveReference(hrefUrl).String()
}
func URLDomain(val string) string {
if u, err := url.Parse(val); err == nil {
return u.Host
}
return val
}

View File

@@ -0,0 +1,63 @@
package htmlutil
import (
"bytes"
"regexp"
"strings"
"golang.org/x/net/html"
)
var whitespaceRegex = regexp.MustCompile(`[\s]+`)
func HTML(node *html.Node) string {
writer := strings.Builder{}
html.Render(&writer, node)
return writer.String()
}
func InnerHTML(node *html.Node) string {
writer := strings.Builder{}
for c := node.FirstChild; c != nil; c = c.NextSibling {
html.Render(&writer, c)
}
return writer.String()
}
func Attr(node *html.Node, key string) string {
for _, a := range node.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val
}
}
return ""
}
func Text(node *html.Node) string {
text := make([]string, 0)
isTextNode := func(n *html.Node) bool {
return n.Type == html.TextNode
}
for _, n := range FindNodes(node, isTextNode) {
text = append(text, strings.TrimSpace(n.Data))
}
return strings.Join(text, " ")
}
func ExtractText(content string) string {
tokenizer := html.NewTokenizer(strings.NewReader(content))
buffer := bytes.Buffer{}
for {
token := tokenizer.Next()
if token == html.ErrorToken {
break
}
if token == html.TextToken {
buffer.WriteString(html.UnescapeString(string(tokenizer.Text())))
}
}
text := buffer.String()
text = strings.TrimSpace(text)
text = whitespaceRegex.ReplaceAllLiteralString(text, " ")
return text
}

View File

@@ -0,0 +1,26 @@
package htmlutil
import "testing"
func TestExtractText(t *testing.T) {
testcases := [][2]string{
{"hello", "<div>hello</div>"},
{"hello world", "<div>hello</div> world"},
{"helloworld", "<div>hello</div>world"},
{"hello world", "hello <div>world</div>"},
{"helloworld", "hello<div>world</div>"},
{"hello world!", "hello <div>world</div>!"},
{"hello world !", "hello <div> world\r\n </div>!"},
}
for _, testcase := range testcases {
want := testcase[0]
base := testcase[1]
have := ExtractText(base)
if want != have {
t.Logf("base: %#v\n", base)
t.Logf("want: %#v\n", want)
t.Logf("have: %#v\n", have)
t.Fail()
}
}
}

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,280 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package readability
import (
"bytes"
"errors"
"fmt"
"io"
"math"
"regexp"
"strings"
"github.com/nkanaev/yarr/src/content/htmlutil"
"golang.org/x/net/html"
)
const (
defaultTagsToScore = "section,h2,h3,h4,h5,h6,p,td,pre,div"
)
var (
divToPElementsRegexp = regexp.MustCompile(`(?i)<(a|blockquote|dl|div|img|ol|p|pre|table|ul)`)
sentenceRegexp = regexp.MustCompile(`\.( |$)`)
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
)
type nodeScores map[*html.Node]float32
// ExtractContent returns relevant content.
func ExtractContent(page io.Reader) (string, error) {
root, err := html.Parse(page)
if err != nil {
return "", err
}
for _, trash := range htmlutil.Query(root, "script,style") {
if trash.Parent != nil {
trash.Parent.RemoveChild(trash)
}
}
transformMisusedDivsIntoParagraphs(root)
removeUnlikelyCandidates(root)
scores := getCandidates(root)
//log.Printf("[Readability] Candidates: %v", candidates)
best := getTopCandidate(scores)
if best == nil {
for _, body := range htmlutil.Query(root, "body") {
best = body
break
}
if best == nil {
return "", errors.New("failed to extract content")
}
}
//log.Printf("[Readability] TopCandidate: %v", topCandidate)
output := getArticle(best, scores)
return output, nil
}
// Now that we have the top candidate, look through its siblings for content that might also be related.
// Things like preambles, content split by ads that we removed, etc.
func getArticle(best *html.Node, scores nodeScores) string {
output := bytes.NewBufferString("<div>")
siblingScoreThreshold := float32(math.Max(10, float64(scores[best]*.2)))
nodelist := make([]*html.Node, 0)
nodelist = append(nodelist, best)
// Get the candidate's siblings
for n := best.NextSibling; n != nil; n = n.NextSibling {
nodelist = append(nodelist, n)
}
for n := best.PrevSibling; n != nil; n = n.PrevSibling {
nodelist = append(nodelist, n)
}
for _, node := range nodelist {
append := false
isP := node.Data == "p"
if node == best {
append = true
} else if scores[node] >= siblingScoreThreshold {
append = true
} else {
if isP {
linkDensity := getLinkDensity(node)
content := htmlutil.Text(node)
contentLength := len(content)
if contentLength >= 80 && linkDensity < .25 {
append = true
} else if contentLength < 80 && linkDensity == 0 && sentenceRegexp.MatchString(content) {
append = true
}
}
}
if append {
tag := "div"
if isP {
tag = "p"
}
fmt.Fprintf(output, "<%s>%s</%s>", tag, htmlutil.InnerHTML(node), tag)
}
}
output.Write([]byte("</div>"))
return output.String()
}
func removeUnlikelyCandidates(root *html.Node) {
body := htmlutil.Query(root, "body")
if len(body) == 0 {
return
}
for _, node := range htmlutil.Query(body[0], "*") {
str := htmlutil.Attr(node, "class") + htmlutil.Attr(node, "id")
if htmlutil.Closest(node, "table,code") != nil {
continue
}
blacklisted := (blacklistCandidatesRegexp.MatchString(str) ||
(unlikelyCandidatesRegexp.MatchString(str) &&
!okMaybeItsACandidateRegexp.MatchString(str)))
if blacklisted && node.Parent != nil {
node.Parent.RemoveChild(node)
}
}
}
func getTopCandidate(scores nodeScores) *html.Node {
var top *html.Node
var max float32
for node, score := range scores {
if score > max {
top = node
max = score
}
}
return top
}
// Loop through all paragraphs, and assign a score to them based on how content-y they look.
// Then add their score to their parent node.
// A score is determined by things like number of commas, class names, etc.
// Maybe eventually link density.
func getCandidates(root *html.Node) nodeScores {
scores := make(nodeScores)
for _, node := range htmlutil.Query(root, defaultTagsToScore) {
text := htmlutil.Text(node)
// If this paragraph is less than 25 characters, don't even count it.
if len(text) < 25 {
continue
}
parentNode := node.Parent
grandParentNode := parentNode.Parent
if _, found := scores[parentNode]; !found {
scores[parentNode] = scoreNode(parentNode)
}
if grandParentNode != nil {
if _, found := scores[grandParentNode]; !found {
scores[grandParentNode] = scoreNode(grandParentNode)
}
}
// Add a point for the paragraph itself as a base.
contentScore := float32(1.0)
// Add points for any commas within this paragraph.
contentScore += float32(strings.Count(text, ",") + 1)
// For every 100 characters in this paragraph, add another point. Up to 3 points.
contentScore += float32(math.Min(float64(int(len(text)/100.0)), 3))
scores[parentNode] += contentScore
if grandParentNode != nil {
scores[grandParentNode] += contentScore / 2.0
}
}
// Scale the final candidates score based on link density. Good content
// should have a relatively small link density (5% or less) and be mostly
// unaffected by this operation
for node := range scores {
scores[node] *= (1 - getLinkDensity(node))
}
return scores
}
func scoreNode(node *html.Node) float32 {
var score float32
switch node.Data {
case "div":
score += 5
case "pre", "td", "blockquote", "img":
score += 3
case "address", "ol", "ul", "dl", "dd", "dt", "li", "form":
score -= 3
case "h1", "h2", "h3", "h4", "h5", "h6", "th":
score -= 5
}
return score + getClassWeight(node)
}
// Get the density of links as a percentage of the content
// This is the amount of text that is inside a link divided by the total text in the node.
func getLinkDensity(n *html.Node) float32 {
textLength := len(htmlutil.Text(n))
if textLength == 0 {
return 0
}
linkLength := 0.0
for _, a := range htmlutil.Query(n, "a") {
linkLength += float64(len(htmlutil.Text(a)))
}
return float32(linkLength) / float32(textLength)
}
// Get an elements class/id weight. Uses regular expressions to tell if this
// element looks good or bad.
func getClassWeight(node *html.Node) float32 {
weight := 0
class := htmlutil.Attr(node, "class")
id := htmlutil.Attr(node, "id")
if class != "" {
if negativeRegexp.MatchString(class) {
weight -= 25
}
if positiveRegexp.MatchString(class) {
weight += 25
}
}
if id != "" {
if negativeRegexp.MatchString(id) {
weight -= 25
}
if positiveRegexp.MatchString(id) {
weight += 25
}
}
return float32(weight)
}
func transformMisusedDivsIntoParagraphs(root *html.Node) {
for _, node := range htmlutil.Query(root, "div") {
if !divToPElementsRegexp.MatchString(htmlutil.InnerHTML(node)) {
node.Data = "p"
}
}
}

Some files were not shown because too many files have changed in this diff Show More