456 Commits

Author SHA1 Message Date
Nazar Kanaev
4983e18e23 switch to articles feed on click 2024-04-17 22:04:14 +01:00
Nazar Kanaev
e1954e4cba update changelog 2024-04-17 21:45:25 +01:00
Nazar Kanaev
58420ae52b editable link fixes 2024-04-17 21:44:16 +01:00
Adam Szkoda
b01f71de1a Editable feed link 2024-04-17 21:26:09 +01:00
Nazar Kanaev
379aaed39e update changelog 2024-04-17 21:19:04 +01:00
Nazar Kanaev
dc20932060 apply selected theme to the login page 2024-04-17 21:17:04 +01:00
Dark Dragon
96835ebd33 Set entrypoint to yarr executable
This allows passing args as CMD directly without knowing the path to yarr within the container
2024-04-09 12:19:09 +01:00
nkanaev
c896f779b5 Update dockerfile 2024-04-09 11:21:11 +01:00
xfzv
5f606b1c40 install-linux.sh - ensure $HOME/.local/share/{applications,icons} dirs exist 2024-03-19 10:51:25 +00:00
Nazar Kanaev
9d5b8d99f7 bump golang/x/* libs 2023-10-24 21:53:27 +01:00
Nazar Kanaev
13c047fc21 update makefile 2023-10-24 21:53:27 +01:00
Dan Ford
55751b3eb6 Update build-docker-image 2023-10-24 21:43:00 +01:00
Dan Ford
b961502a17 Add docker image build action 2023-10-24 21:43:00 +01:00
Nazar Kanaev
a895145586 sort articles before storing 2023-09-30 23:19:58 +01:00
nkanaev
5aec3b4dab Update readme.md 2023-09-23 21:44:41 +01:00
Nazar Kanaev
d787060a24 move files 2023-09-23 21:39:53 +01:00
Nazar Kanaev
c1a29418eb reorganise bin files 2023-09-23 21:32:32 +01:00
Nazar Kanaev
17847f999c update changelog 2023-09-23 21:10:21 +01:00
Will Harding
3adcddc70c Pull atom xhtml title from nested elements
The Atom spec says that any title marked with a type of "xhtml" should be
contained in a div element[1] so we need to use the full XML text when
extracting the text.

[1] https://www.rfc-editor.org/rfc/rfc4287#section-3.1
2023-09-23 21:08:22 +01:00
nkanaev
c76ff26bd6 Update readme.md 2023-09-14 13:44:35 +01:00
icefed
50f8648f64 update readme 2023-09-14 13:42:57 +01:00
icefed
5f82a9e339 add fever doc & fix fever issues 2023-09-14 13:42:57 +01:00
nkanaev
3278ba4eac Update changelog.txt 2023-09-11 13:56:58 +01:00
icefed
9fc72f8b68 update 2023-09-11 13:56:11 +01:00
icefed
b7b707bd43 update 2023-09-11 13:56:11 +01:00
icefed
7cf27e0fde fix review 2023-09-11 13:56:11 +01:00
icefed
66f2a973a3 fever api support
Fever API spec: https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md
2023-09-11 13:56:11 +01:00
Nazar Kanaev
7ecbbff18a update changelog 2023-09-07 18:21:31 +01:00
Nazar Kanaev
850ce195a0 fix atom links 2023-09-07 18:19:17 +01:00
Nazar Kanaev
479aebd023 update changelog 2023-09-01 17:40:13 +01:00
Nazar Kanaev
9b178d1fb3 fix relative article links 2023-09-01 17:38:37 +01:00
Nazar Kanaev
3ab098db5c update changelog 2023-08-21 10:39:19 +01:00
Nazar Kanaev
6d16e93008 fix sqlite conflict handling for feeds/folders 2023-08-19 21:51:05 +01:00
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
334 changed files with 464696 additions and 14616 deletions

38
.github/workflows/build-docker-image vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Publish Docker Image
on:
push:
tags: [ 'v*.*.*', 'v*.*', 'v*', 'latest' ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: nkanaev/yarr
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
with:
context: .
file: ./etc/dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -2,15 +2,21 @@ name: build
on: on:
push: push:
tags: [v*] tags: ['v*', 'test*']
jobs: jobs:
build_macos: build_macos:
name: Build for MacOS name: Build for MacOS
runs-on: macos-10.15 runs-on: macos-13
steps: steps:
- {name: "Checkout", uses: actions/checkout@v2} - name: "Checkout"
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}} uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: "Setup Go"
uses: actions/setup-go@v2
with:
go-version: '^1.17'
- name: Cache Go Modules - name: Cache Go Modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
@@ -28,10 +34,16 @@ jobs:
build_windows: build_windows:
name: Build for Windows name: Build for Windows
runs-on: windows-2019 runs-on: windows-2022
steps: steps:
- {name: "Checkout", uses: actions/checkout@v2} - name: "Checkout"
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}} uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: "Setup Go"
uses: actions/setup-go@v2
with:
go-version: '^1.17'
- name: Cache Go Modules - name: Cache Go Modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
@@ -49,11 +61,16 @@ jobs:
build_linux: build_linux:
name: Build for Linux name: Build for Linux
runs-on: ubuntu-18.04 runs-on: ubuntu-22.04
steps: steps:
- {name: "Checkout", uses: actions/checkout@v2} - name: "Checkout"
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}} uses: actions/checkout@v2
- {name: "Setup Go", uses: actions/setup-go@v2, with: {go-version: '^1.14'}} with:
submodules: 'recursive'
- name: "Setup Go"
uses: actions/setup-go@v2
with:
go-version: '^1.17'
- name: Cache Go Modules - name: Cache Go Modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
@@ -72,6 +89,7 @@ jobs:
create_release: create_release:
name: Create Release name: Create Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ !contains(github.ref, 'test') }}
needs: [build_macos, build_windows, build_linux] needs: [build_macos, build_windows, build_linux]
steps: steps:
- name: Create Release - name: Create Release
@@ -113,7 +131,7 @@ jobs:
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-windows.zip asset_path: ./yarr-windows.zip
asset_name: yarr-${{ github.ref }}-windows32.zip asset_name: yarr-${{ github.ref }}-windows64.zip
asset_content_type: application/zip asset_content_type: application/zip
- name: Upload Linux - name: Upload Linux
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
@@ -122,5 +140,5 @@ jobs:
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-linux.zip asset_path: ./yarr-linux.zip
asset_name: yarr-${{ github.ref }}-linux32.zip asset_name: yarr-${{ github.ref }}-linux64.zip
asset_content_type: application/zip asset_content_type: application/zip

2
.gitignore vendored
View File

@@ -1,5 +1,3 @@
/server/assets.go
/gofeed
/_output /_output
/yarr /yarr
*.db *.db

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

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,396 +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">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>
<body class="theme-light">
<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'}"
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>Refresh</b-dropdown-header>
<b-dropdown-item-button @click.stop="refreshRate = min" v-for="min in [0, 60]">
<span class="icon mr-1" :class="{invisible: refreshRate != min}">{% inline "check.svg" %}</span>
<span v-if="min == 0">Manually</span>
<span v-if="min == 60">Every hour</span>
</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; z-index: -1;">
<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-divider v-if="authenticated"></b-dropdown-divider>
<b-dropdown-item-button v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
</b-dropdown-item-button>
</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 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"
v-b-tooltip.hover.bottom="'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>
<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 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="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 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=='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>
<span class="icon flex-shrink-0 mx-2"
v-b-tooltip.hover.top="feed_errors[feed.id]"
v-if="feed_errors[feed.id]">
{% inline "alert-circle.svg" %}
</span>
<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

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
cmd/feed2json/main.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

@@ -1,12 +1,12 @@
package main package main
import ( import (
"io/ioutil"
"flag" "flag"
"io/ioutil"
"strings" "strings"
) )
var rsrc = `1 VERSIONINFO var rsrc = `1 VERSIONINFO
FILEVERSION {VERSION_COMMA},0,0 FILEVERSION {VERSION_COMMA},0,0
PRODUCTVERSION {VERSION_COMMA},0,0 PRODUCTVERSION {VERSION_COMMA},0,0
BEGIN BEGIN

View File

@@ -85,9 +85,9 @@ func main() {
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} { for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
outfile := fmt.Sprintf("icon_%dx%d.png", res, res) outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
if res == 1024 || res == 64 { 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), "sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
iconFile, "--out", path.Join(iconsetDir, outfile), iconFile, "--out", path.Join(iconsetDir, outfile),
} }

41
cmd/readability/main.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)
}

155
cmd/yarr/main.go Normal file
View File

@@ -0,0 +1,155 @@
package main
import (
"bufio"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/nkanaev/yarr/src/platform"
"github.com/nkanaev/yarr/src/server"
"github.com/nkanaev/yarr/src/storage"
)
var Version string = "0.0"
var GitHash string = "unknown"
var OptList = make([]string, 0)
func opt(envVar, defaultValue string) string {
OptList = append(OptList, envVar)
value := os.Getenv(envVar)
if value != "" {
return value
}
return defaultValue
}
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
scanner := bufio.NewScanner(authfile)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("wrong syntax (expected `username:password`)")
}
username = parts[0]
password = parts[1]
break
}
return username, password, nil
}
func main() {
platform.FixConsoleIfNeeded()
var addr, db, authfile, auth, certfile, keyfile, basepath, logfile string
var ver, open bool
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = func() {
out := flag.CommandLine.Output()
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintln(out, "\nThe environmental variables, if present, will be used to provide\nthe default values for the params above:")
fmt.Fprintln(out, " ", strings.Join(OptList, ", "))
}
flag.StringVar(&addr, "addr", opt("YARR_ADDR", "127.0.0.1:7070"), "address to run server on")
flag.StringVar(&basepath, "base", opt("YARR_BASE", ""), "base path of the service url")
flag.StringVar(&authfile, "auth-file", opt("YARR_AUTHFILE", ""), "`path` to a file containing username:password. Takes precedence over --auth (or YARR_AUTH)")
flag.StringVar(&auth, "auth", opt("YARR_AUTH", ""), "string with username and password in the format `username:password`")
flag.StringVar(&certfile, "cert-file", opt("YARR_CERTFILE", ""), "`path` to cert file for https")
flag.StringVar(&keyfile, "key-file", opt("YARR_KEYFILE", ""), "`path` to key file for https")
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
flag.StringVar(&logfile, "log-file", opt("YARR_LOGFILE", ""), "`path` to log file to use instead of stdout")
flag.BoolVar(&ver, "version", false, "print application version")
flag.BoolVar(&open, "open", false, "open the server in browser")
flag.Parse()
if ver {
fmt.Printf("v%s (%s)\n", Version, GitHash)
return
}
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
if logfile != "" {
file, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
log.Fatal("Failed to setup log file: ", err)
}
defer file.Close()
log.SetOutput(file)
} else {
log.SetOutput(os.Stdout)
}
configPath, err := os.UserConfigDir()
if err != nil {
log.Fatal("Failed to get config dir: ", err)
}
if db == "" {
storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil {
log.Fatal("Failed to create app config dir: ", err)
}
db = filepath.Join(storagePath, "storage.db")
}
log.Printf("using db file %s", db)
var username, password string
if authfile != "" {
f, err := os.Open(authfile)
if err != nil {
log.Fatal("Failed to open auth file: ", err)
}
defer f.Close()
username, password, err = parseAuthfile(f)
if err != nil {
log.Fatal("Failed to parse auth file: ", err)
}
} else if auth != "" {
username, password, err = parseAuthfile(strings.NewReader(auth))
if err != nil {
log.Fatal("Failed to parse auth literal: ", err)
}
}
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
log.Fatalf("Both cert & key files are required")
}
store, err := storage.New(db)
if err != nil {
log.Fatal("Failed to initialise database: ", err)
}
srv := server.NewServer(store, addr)
if basepath != "" {
srv.BasePath = "/" + strings.Trim(basepath, "/")
}
if certfile != "" && keyfile != "" {
srv.CertFile = certfile
srv.KeyFile = keyfile
}
if username != "" && password != "" {
srv.Username = username
srv.Password = password
}
log.Printf("starting server at %s", srv.GetAddr())
if open {
platform.Open(srv.GetAddr())
}
platform.Start(srv)
}

47
cmd/yarr/main_test.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"strings"
"testing"
)
func TestPasswordFromAuthfile(t *testing.T) {
for _, tc := range [...]struct {
authfile string
expectedUsername string
expectedPassword string
expectedError bool
}{
{
authfile: "username:password",
expectedUsername: "username",
expectedPassword: "password",
expectedError: false,
},
{
authfile: "username-and-no-password",
expectedError: true,
},
{
authfile: "username:password:with:columns",
expectedUsername: "username",
expectedPassword: "password:with:columns",
expectedError: false,
},
} {
t.Run(tc.authfile, func(t *testing.T) {
username, password, err := parseAuthfile(strings.NewReader(tc.authfile))
if tc.expectedUsername != username {
t.Errorf("expected username %q, got %q", tc.expectedUsername, username)
}
if tc.expectedPassword != password {
t.Errorf("expected password %q, got %q", tc.expectedPassword, password)
}
if tc.expectedError && err == nil {
t.Errorf("expected error, got nil")
} else if !tc.expectedError && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}

44
doc/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 -f etc/dockerfile .
## ARM compilation
The instructions below are to cross-compile *yarr* to `Linux/ARM*`.
Build:
docker build -t yarr.arm -f etc/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"

View File

@@ -1,5 +1,87 @@
# upcoming # upcoming
- (new) Fever API support (thanks to @icefed)
- (new) editable feed link (thanks to @adaszko)
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
- (fix) relative article links (thanks to @adazsko for the report)
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
- (fix) parsing atom feed titles (thanks to @wnh)
- (fix) sorting same-day batch articles (thanks to @lamescholar for the report)
- (fix) showing login page in the selected theme (thanks to @feddiriko for the report)
# 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) autorefresh rate
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag` - (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
- (new) show feed errors in feed management modal - (new) show feed errors in feed management modal
@@ -8,6 +90,7 @@
- (new) `-auth-file` flag for authentication - (new) `-auth-file` flag for authentication
- (new) `-cert-file` & `-key-file` flags for TLS - (new) `-cert-file` & `-key-file` flags for TLS
- (fix) wrapping long words in the ui to prevent vertical scroll - (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) # v1.1 (2020-10-05)

19
doc/fever.md Normal file
View File

@@ -0,0 +1,19 @@
# Fever API support
Fever API is a kind of RSS HTTP API interface, because the Fever API definition is not very clear, so the implementation of Fever server and Client may have some compatibility problems.
The Fever API implemented by Yarr is based on the Fever API spec: https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md.
Here are some Apps that have been tested to work with yarr. Feel free to test other Clients/Apps and update the list here.
> Different apps support different URL/Address formats. Please note whether the URL entered has `http://` scheme and `/` suffix.
| App | Platforms | Config Server URL |
|:------------------------------------------------------------------------- | ---------------- |:--------------------------------------------------- |
| [Reeder](https://reederapp.com/) | MacOS<br>iOS | 127.0.0.1:7070/fever<br>http://127.0.0.1:7070/fever |
| [ReadKit](https://readkit.app/) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
| [Fluent Reader](https://github.com/yang991178/fluent-reader) | MacOS<br>Windows | http://127.0.0.1:7070/fever/ |
| [Unread](https://apps.apple.com/us/app/unread-an-rss-reader/id1363637349) | iOS | http://127.0.0.1:7070/fever |
| [Fiery Feeds](https://voidstern.net/fiery-feeds) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
If you are having trouble using Fever, please open an issue and @icefed, thanks.

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

View File

@@ -1,28 +0,0 @@
# hacking
## build
Install `Go >= 1.14` and `gcc`. Get the source code:
```sh
git clone https://github.com/nkanaev/yarr.git
git clone https://github.com/nkanaev/gofeed.git
mv gofeed yarr
cd yarr
```
Then:
```sh
# create a binary for the host os
make build_macos # -> _output/macos/yarr.app
make build_linux # -> _output/linux/yarr
make build_windows # -> _output/windows/yarr.exe
# ... or run locally (for testing & hacking)
go run main.go # starts a server at http://localhost:7070
```
## code of conduct
Be excellent to each other. Party on, dudes!

View File

@@ -1,15 +0,0 @@
# Linux desktop
Grab the latest linux binary, then run:
```
$ sudo mv /path/to/yarr /usr/local/bin
$ sudo tee /usr/local/share/applications/yarr.desktop >/dev/null <<EOF
[Desktop Entry]
Name=yarr
Exec=yarr -open
Icon=rss
Type=Application
Categories=Internet;
EOF
```

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

View File

@@ -1,11 +0,0 @@
- add: switch to `embed` once 1.16 is out
https://tip.golang.org/pkg/embed/
- add: enclosures (podcasts etc)
https://github.com/shagr4th/yarr/commits/master
- fix: moving newly added feed makes it disappear
- fix: broken base link in https://applieddivinitystudies.com/
- fix: migrate to cascade delete
https://sqlite.org/foreignkeys.html#fk_actions
- fix: loading items (by scrolling down) is glitching while feeds are refreshing
- doc: self-hosting instructions (including certificates)
- etc: test gofeed against real-world feeds, compare results with https://pypi.org/project/feedparser/

View File

@@ -1,4 +1,4 @@
FROM golang:alpine AS build FROM golang:alpine3.18 AS build
RUN apk add build-base git RUN apk add build-base git
WORKDIR /src WORKDIR /src
COPY . . COPY . .
@@ -9,4 +9,5 @@ RUN apk add --no-cache ca-certificates && \
update-ca-certificates update-ca-certificates
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
EXPOSE 7070 EXPOSE 7070
CMD ["/usr/local/bin/yarr", "-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"] ENTRYPOINT ["/usr/local/bin/yarr"]
CMD ["-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]

44
etc/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 linux" \
-ldflags="-s -w" \
-o /root/out/yarr.arm64 ./cmd/yarr
RUN env \
CC=arm-linux-gnueabihf-gcc \
CGO_ENABLED=1 \
GOOS=linux GOARCH=arm GOARM=7 \
go build \
-tags "sqlite_foreign_keys linux" \
-ldflags="-s -w" \
-o /root/out/yarr.arm7 ./cmd/yarr
CMD ["/bin/bash"]

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

@@ -0,0 +1,31 @@
#!/bin/bash
if [[ ! -d "$HOME/.local/share/applications" ]]; then
mkdir -p "$HOME/.local/share/applications"
fi
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
if [[ ! -d "$HOME/.local/share/icons" ]]; then
mkdir -p "$HOME/.local/share/icons"
fi
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 module github.com/nkanaev/yarr
go 1.14 go 1.17
require ( require (
github.com/PuerkitoBio/goquery v1.5.1 github.com/mattn/go-sqlite3 v1.14.7
github.com/getlantern/systray v1.0.4 golang.org/x/net v0.17.0
github.com/mattn/go-sqlite3 v1.14.0 golang.org/x/sys v0.13.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
) )
replace github.com/mmcdole/gofeed => ./gofeed require golang.org/x/text v0.13.0 // indirect

110
go.sum
View File

@@ -1,73 +1,45 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 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/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 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/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 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/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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=

110
main.go
View File

@@ -1,110 +0,0 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/nkanaev/yarr/platform"
"github.com/nkanaev/yarr/server"
"github.com/nkanaev/yarr/storage"
sdopen "github.com/skratchdot/open-golang/open"
)
var Version string = "0.0"
var GitHash string = "unknown"
func main() {
var addr, db, authfile, certfile, keyfile string
var ver, open bool
flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on")
flag.StringVar(&authfile, "auth-file", "", "path to a file containing username:password")
flag.StringVar(&server.BasePath, "base", "", "base path of the service url")
flag.StringVar(&certfile, "cert-file", "", "path to cert file for https")
flag.StringVar(&keyfile, "key-file", "", "path to key file for https")
flag.StringVar(&db, "db", "", "storage file path")
flag.BoolVar(&ver, "version", false, "print application version")
flag.BoolVar(&open, "open", false, "open the server in browser")
flag.Parse()
if ver {
fmt.Printf("v%s (%s)\n", Version, GitHash)
return
}
if server.BasePath != "" && !strings.HasPrefix(server.BasePath, "/") {
server.BasePath = "/" + server.BasePath
}
if server.BasePath != "" && strings.HasSuffix(server.BasePath, "/") {
server.BasePath = strings.TrimSuffix(server.BasePath, "/")
}
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 db == "" {
storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil {
logger.Fatal("Failed to create app config dir: ", err)
}
db = filepath.Join(storagePath, "storage.db")
}
logger.Printf("using db file %s", db)
var username, password string
if authfile != "" {
f, err := os.Open(authfile)
if err != nil {
logger.Fatal("Failed to open auth file: ", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 2 {
logger.Fatalf("Invalid auth: %v (expected `username:password`)", line)
}
username = parts[0]
password = parts[1]
break
}
}
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
logger.Fatalf("Both cert & key files are required")
}
store, err := storage.New(db, logger)
if err != nil {
logger.Fatal("Failed to initialise database: ", err)
}
srv := server.New(store, logger, addr)
if certfile != "" && keyfile != "" {
srv.CertFile = certfile
srv.KeyFile = keyfile
}
if username != "" && password != "" {
srv.Username = username
srv.Password = password
}
logger.Printf("starting server at %s", srv.GetAddr())
if open {
sdopen.Run(srv.GetAddr())
}
platform.Start(srv)
}

View File

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

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,197 +0,0 @@
// +build macos
// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray)
package platform
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,15 +1,38 @@
# yarr # 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. ## usage
Longer-term plans include a self-hosted solution for individuals.
[download](https://github.com/nkanaev/yarr/releases/latest) The latest prebuilt binaries for Linux/MacOS/Windows AMD64 are available
[here](https://github.com/nkanaev/yarr/releases/latest). Installation instructions:
* MacOS
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".
* Windows
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).
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
For self-hosting, see `yarr -h` for auth, tls & server configuration flags.
See more:
* [Building from source code](doc/build.md)
* [Fever API support](doc/fever.md)
## credits ## credits

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.go", buf.Bytes(), 0644)
}

View File

@@ -1,51 +0,0 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"time"
)
func userIsAuthenticated(req *http.Request, username, password string) bool {
cookie, _ := req.Cookie("auth")
if cookie == nil {
return false
}
parts := strings.Split(cookie.Value, ":")
if len(parts) != 2 || !stringsEqual(parts[0], username) {
return false
}
return stringsEqual(parts[1], secret(username, password))
}
func userAuthenticate(rw http.ResponseWriter, username, password string) {
expires := time.Now().Add(time.Hour * 24 * 7) // 1 week
var cookiePath string
if BasePath != "" {
cookiePath = BasePath
} else {
cookiePath = "/"
}
cookie := http.Cookie{
Name: "auth",
Value: username + ":" + secret(username, password),
Expires: expires,
Path: cookiePath,
}
http.SetCookie(rw, &cookie)
}
func stringsEqual(p1, p2 string) bool {
return subtle.ConstantTimeCompare([]byte(p1), []byte(p2)) == 1
}
func secret(msg, key string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(msg))
src := mac.Sum(nil)
return hex.EncodeToString(src)
}

View File

@@ -1,313 +0,0 @@
package server
import (
"bytes"
"errors"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/mmcdole/gofeed"
"github.com/nkanaev/yarr/storage"
"io/ioutil"
"net"
"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)
}
func (c *Client) getConditional(url, lastModified, etag string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("If-Modified-Since", lastModified)
req.Header.Set("If-None-Match", etag)
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 {
base, err := url.Parse(websiteUrl)
if err != nil {
return nil, err
}
res, err := defaultClient.get(websiteUrl)
if err != nil {
return nil, err
}
defer res.Body.Close()
doc, err := goquery.NewDocumentFromReader(res.Body)
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, db *storage.Storage) ([]storage.Item, error) {
var res *http.Response
var err error
httpState := db.GetHTTPState(f.Id)
if httpState != nil {
res, err = defaultClient.getConditional(f.FeedLink, httpState.LastModified, httpState.Etag)
} else {
res, err = defaultClient.get(f.FeedLink)
}
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode/100 == 4 || res.StatusCode/100 == 5 {
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: %d)", f.FeedLink, res.StatusCode)
return nil, errors.New(errmsg)
}
if res.StatusCode == 304 {
return nil, nil
}
lastModified := res.Header.Get("Last-Modified")
etag := res.Header.Get("Etag")
if lastModified != "" || etag != "" {
db.SetHTTPState(f.Id, lastModified, etag)
}
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.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
}).DialContext,
DisableKeepAlives: true,
TLSHandshakeTimeout: time.Second * 10,
}
httpClient := &http.Client{
Timeout: time.Second * 30,
Transport: transport,
}
defaultClient = &Client{
httpClient: httpClient,
userAgent: "Yarr/1.0",
}
}

View File

@@ -1,530 +0,0 @@
package server
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"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).ManualAuth(),
p("/static/*path", StaticHandler).ManualAuth(),
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/errors", FeedErrorsHandler),
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) {
h := handler(req)
if h.requiresAuth() && !userIsAuthenticated(req, h.Username, h.Password) {
if req.Method == "POST" {
username := req.FormValue("username")
password := req.FormValue("password")
if stringsEqual(username, h.Username) && stringsEqual(password, h.Password) {
userAuthenticate(rw, username, password)
http.Redirect(rw, req, req.URL.Path, http.StatusFound)
return
}
}
if assets != nil {
asset := assets["login.html"]
rw.Header().Set("Content-Type", "text/html")
rw.Header().Set("Content-Encoding", "gzip")
rw.Write(*asset.gzip())
return
} else {
f, err := os.Open("assets/login.html")
if err != nil {
handler(req).log.Print(err)
return
}
io.Copy(rw, f)
return
}
}
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 FeedErrorsHandler(rw http.ResponseWriter, req *http.Request) {
errors := db(req).GetFeedErrors()
writeJSON(rw, errors)
}
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))
icon, err := findFavicon(storedFeed.Link, storedFeed.FeedLink)
if icon != nil {
db(req).UpdateFeedIcon(storedFeed.Id, icon)
}
if err != nil {
handler(req).log.Printf("Failed to find favicon for %s (%d): %s", storedFeed.FeedLink, storedFeed.Id, err)
}
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.MarkFilter{}
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) {
if _, ok := settings["refresh_rate"]; ok {
handler(req).refreshRate <- db(req).GetSettingsValueInt64("refresh_rate")
}
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"
"log"
"net/http"
)
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,50 +0,0 @@
package server
import (
"net/http"
"regexp"
)
var BasePath string = ""
type Route struct {
url string
urlRegex *regexp.Regexp
handler func(http.ResponseWriter, *http.Request)
manualAuth bool
}
func (r Route) ManualAuth() Route {
r.manualAuth = true
return r
}
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(reqPath string) (*Route, map[string]string) {
vars := make(map[string]string)
for _, route := range routes {
if route.urlRegex.MatchString(reqPath) {
matches := route.urlRegex.FindStringSubmatchIndex(reqPath)
for i, key := range route.urlRegex.SubexpNames()[1:] {
vars[key] = reqPath[matches[i*2+2]:matches[i*2+3]]
}
return &route, vars
}
}
return nil, nil
}

View File

@@ -1,226 +0,0 @@
package server
import (
"context"
"log"
"net/http"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/nkanaev/yarr/storage"
)
type Handler struct {
Addr string
db *storage.Storage
log *log.Logger
feedQueue chan storage.Feed
queueSize *int32
refreshRate chan int64
// auth
Username string
Password string
// https
CertFile string
KeyFile string
}
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,
refreshRate: make(chan int64),
}
}
func (h *Handler) GetAddr() string {
proto := "http"
if h.CertFile != "" && h.KeyFile != "" {
proto = "https"
}
return proto + "://" + h.Addr + BasePath
}
func (h *Handler) Start() {
h.startJobs()
s := &http.Server{Addr: h.Addr, Handler: h}
var err error
if h.CertFile != "" && h.KeyFile != "" {
err = s.ListenAndServeTLS(h.CertFile, h.KeyFile)
} else {
err = s.ListenAndServe()
}
if err != http.ErrServerClosed {
h.log.Fatal(err)
}
}
func unsafeMethod(method string) bool {
return method == "POST" || method == "PUT" || method == "DELETE"
}
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
reqPath := req.URL.Path
if BasePath != "" {
if !strings.HasPrefix(reqPath, BasePath) {
rw.WriteHeader(http.StatusNotFound)
return
}
reqPath = strings.TrimPrefix(req.URL.Path, BasePath)
if reqPath == "" {
http.Redirect(rw, req, BasePath+"/", http.StatusFound)
return
}
}
route, vars := getRoute(reqPath)
if route == nil {
rw.WriteHeader(http.StatusNotFound)
return
}
if h.requiresAuth() && !route.manualAuth {
if unsafeMethod(req.Method) && req.Header.Get("X-Requested-By") != "yarr" {
rw.WriteHeader(http.StatusUnauthorized)
return
}
if !userIsAuthenticated(req, h.Username, h.Password) {
rw.WriteHeader(http.StatusUnauthorized)
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, h.db)
atomic.AddInt32(h.queueSize, -1)
if err != nil {
h.log.Printf("Failed to fetch %s (%d): %s", feed.FeedLink, feed.Id, err)
h.db.SetFeedError(feed.Id, err)
continue
}
h.db.CreateItems(items)
syncSearch()
if !feed.HasIcon {
icon, err := findFavicon(feed.Link, feed.FeedLink)
if icon != nil {
h.db.UpdateFeedIcon(feed.Id, icon)
}
if err != nil {
h.log.Printf("Failed to search favicon for %s (%s): %s", feed.Link, feed.FeedLink, 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()
go func() {
var refreshTicker *time.Ticker
refreshTick := make(<-chan time.Time)
for {
select {
case <-refreshTick:
h.fetchAllFeeds()
case val := <-h.refreshRate:
if refreshTicker != nil {
refreshTicker.Stop()
if val == 0 {
refreshTick = make(<-chan time.Time)
}
}
if val > 0 {
refreshTicker = time.NewTicker(time.Duration(val) * time.Minute)
refreshTick = refreshTicker.C
}
}
}
}()
refreshRate := h.db.GetSettingsValueInt64("refresh_rate")
h.refreshRate <- refreshRate
if refreshRate > 0 {
h.fetchAllFeeds()
}
}
func (h Handler) requiresAuth() bool {
return h.Username != "" && h.Password != ""
}
func (h *Handler) fetchAllFeeds() {
h.log.Print("Refreshing all feeds")
h.db.ResetFeedErrors()
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)
}

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

@@ -0,0 +1,13 @@
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

Before

Width:  |  Height:  |  Size: 356 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

Before

Width:  |  Height:  |  Size: 270 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

@@ -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-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>

Before

Width:  |  Height:  |  Size: 269 B

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

Before

Width:  |  Height:  |  Size: 367 B

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

@@ -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-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>

Before

Width:  |  Height:  |  Size: 341 B

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

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

@@ -0,0 +1,425 @@
<!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>
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Change Link
</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>
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}
</span>
</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

@@ -1,11 +1,17 @@
"use strict"; "use strict";
(function() { (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 api = function(method, endpoint, data) {
var headers = {'Content-Type': 'application/json'} var headers = {'Content-Type': 'application/json'}
if (['post', 'put', 'delete'].indexOf(method) !== -1) return xfetch(endpoint, {
headers['x-requested-by'] = 'yarr'
return fetch(endpoint, {
method: method, method: method,
headers: headers, headers: headers,
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -65,6 +71,9 @@
} }
}, },
items: { items: {
get: function(id) {
return api('get', './api/items/' + id).then(json)
},
list: function(query) { list: function(query) {
return api('get', './api/items' + param(query)).then(json) return api('get', './api/items' + param(query)).then(json)
}, },
@@ -87,15 +96,16 @@
return api('get', './api/status').then(json) return api('get', './api/status').then(json)
}, },
upload_opml: function(form) { upload_opml: function(form) {
return fetch('./opml/import', { return xfetch('./opml/import', {
method: 'post', method: 'post',
body: new FormData(form), body: new FormData(form),
}) })
}, },
logout: function() {
return api('post', './logout')
},
crawl: function(url) { crawl: function(url) {
return fetch('./page?url=' + url).then(function(res) { return api('get', './page?url=' + encodeURIComponent(url)).then(json)
return res.text()
})
} }
} }
})() })()

View File

@@ -2,19 +2,6 @@
var TITLE = document.title var TITLE = document.title
function authenticated() {
return /auth=.+/g.test(document.cookie)
}
var FONTS = [
"Arial",
"Courier New",
"Georgia",
"Times New Roman",
"Verdana",
]
var debounce = function(callback, wait) { var debounce = function(callback, wait) {
var timeout var timeout
return function() { return function() {
@@ -26,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', { Vue.directive('scroll', {
inserted: function(el, binding) { inserted: function(el, binding) {
el.addEventListener('scroll', debounce(function(event) { el.addEventListener('scroll', debounce(function(event) {
@@ -58,6 +21,12 @@ Vue.directive('scroll', {
}, },
}) })
Vue.directive('focus', {
inserted: function(el) {
el.focus()
}
})
Vue.component('drag', { Vue.component('drag', {
props: ['width'], props: ['width'],
template: '<div class="drag"></div>', template: '<div class="drag"></div>',
@@ -83,6 +52,96 @@ 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) { function dateRepr(d) {
var sec = (new Date().getTime() - d.getTime()) / 1000 var sec = (new Date().getTime() - d.getTime()) / 1000
var neg = sec < 0 var neg = sec < 0
@@ -125,58 +184,53 @@ Vue.component('relative-time', {
var vm = new Vue({ var vm = new Vue({
created: function() { created: function() {
this.refreshFeeds()
this.refreshStats() this.refreshStats()
}, .then(this.refreshFeeds.bind(this))
mounted: function() { .then(this.refreshItems.bind(this, false))
this.$root.$on('bv::modal::hidden', function(bvEvent, modalId) {
if (vm.settings == 'create') { api.feeds.list_errors().then(function(errors) {
vm.feedNewChoice = [] vm.feed_errors = errors
vm.feedNewChoiceSelected = ''
}
}) })
}, },
data: function() { data: function() {
var s = app.settings
return { return {
'filterSelected': undefined, 'filterSelected': s.filter,
'folders': [], 'folders': [],
'feeds': [], 'feeds': [],
'feedSelected': undefined, 'feedSelected': s.feed,
'feedListWidth': undefined, 'feedListWidth': s.feed_list_width || 300,
'feedNewChoice': [], 'feedNewChoice': [],
'feedNewChoiceSelected': '', 'feedNewChoiceSelected': '',
'items': [], 'items': [],
'itemsPage': { 'itemsHasMore': true,
'cur': 1,
'num': 1,
},
'itemSelected': null, 'itemSelected': null,
'itemSelectedDetails': {}, 'itemSelectedDetails': null,
'itemSelectedReadability': '', 'itemSelectedReadability': '',
'itemSearch': '', 'itemSearch': '',
'itemSortNewestFirst': undefined, 'itemSortNewestFirst': s.sort_newest_first,
'itemListWidth': undefined, 'itemListWidth': s.item_list_width || 300,
'filteredFeedStats': {}, 'filteredFeedStats': {},
'filteredFolderStats': {}, 'filteredFolderStats': {},
'filteredTotalStats': null, 'filteredTotalStats': null,
'settings': 'create', 'settings': '',
'loading': { 'loading': {
'feeds': 0, 'feeds': 0,
'newfeed': false, 'newfeed': false,
'items': false, 'items': false,
'readability': false, 'readability': false,
}, },
'fonts': FONTS, 'fonts': ['', 'serif', 'monospace'],
'feedStats': {}, 'feedStats': {},
'theme': { 'theme': {
'name': 'light', 'name': s.theme_name,
'font': '', 'font': s.theme_font,
'size': 1, 'size': s.theme_size,
}, },
'refreshRate': undefined, 'refreshRate': s.refresh_rate,
'authenticated': authenticated(), 'authenticated': app.authenticated,
'feed_errors': {}, 'feed_errors': {},
} }
}, },
@@ -197,10 +251,24 @@ var vm = new Vue({
return folders return folders
}, },
feedsById: function() { 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() { foldersById: function() {
return this.items.reduce(function(acc, item) { acc[item.id] = item; return acc }, {}) 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() { itemSelectedContent: function() {
if (!this.itemSelected) return '' if (!this.itemSelected) return ''
@@ -208,13 +276,7 @@ var vm = new Vue({
if (this.itemSelectedReadability) if (this.itemSelectedReadability)
return this.itemSelectedReadability return this.itemSelectedReadability
var content = '' return this.itemSelectedDetails.content || ''
if (this.itemSelectedDetails.content)
content = this.itemSelectedDetails.content
else if (this.itemSelectedDetails.description)
content = this.itemSelectedDetails.description
return sanitize(content, this.itemSelectedDetails.link)
}, },
}, },
watch: { watch: {
@@ -245,13 +307,13 @@ var vm = new Vue({
}, },
'filterSelected': function(newVal, oldVal) { 'filterSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this)) api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
this.itemSelected = null this.itemSelected = null
this.computeStats() this.computeStats()
}, },
'feedSelected': function(newVal, oldVal) { 'feedSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this)) api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
this.itemSelected = null this.itemSelected = null
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0 if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
}, },
@@ -263,19 +325,24 @@ var vm = new Vue({
} }
if (this.$refs.content) this.$refs.content.scrollTop = 0 if (this.$refs.content) this.$refs.content.scrollTop = 0
this.itemSelectedDetails = this.itemsById[newVal] api.items.get(newVal).then(function(item) {
if (this.itemSelectedDetails.status == 'unread') { this.itemSelectedDetails = item
this.itemSelectedDetails.status = 'read' if (this.itemSelectedDetails.status == 'unread') {
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1 api.items.update(this.itemSelectedDetails.id, {status: 'read'}).then(function() {
api.items.update(this.itemSelectedDetails.id, {status: this.itemSelectedDetails.status}) 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) { 'itemSearch': debounce(function(newVal) {
this.refreshItems() this.refreshItems()
}, 500), }, 500),
'itemSortNewestFirst': function(newVal, oldVal) { 'itemSortNewestFirst': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this)) api.settings.update({sort_newest_first: newVal}).then(vm.refreshItems.bind(this, false))
}, },
'feedListWidth': debounce(function(newVal, oldVal) { 'feedListWidth': debounce(function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup if (oldVal === undefined) return // do nothing, initial setup
@@ -292,7 +359,7 @@ var vm = new Vue({
}, },
methods: { methods: {
refreshStats: function(loopMode) { refreshStats: function(loopMode) {
api.status().then(function(data) { return api.status().then(function(data) {
if (loopMode && !vm.itemSelected) vm.refreshItems() if (loopMode && !vm.itemSelected) vm.refreshItems()
vm.loading.feeds = data.running vm.loading.feeds = data.running
@@ -303,6 +370,10 @@ var vm = new Vue({
acc[stat.feed_id] = stat acc[stat.feed_id] = stat
return acc return acc
}, {}) }, {})
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
}) })
}, },
getItemsQuery: function() { getItemsQuery: function() {
@@ -336,34 +407,49 @@ var vm = new Vue({
vm.feeds = values[1] vm.feeds = values[1]
}) })
}, },
refreshItems: function() { refreshItems: function(loadMore) {
if (this.feedSelected === null) { if (this.feedSelected === null) {
vm.items = [] vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1} vm.itemsHasMore = false
return return
} }
var query = this.getItemsQuery() var query = this.getItemsQuery()
if (loadMore) {
query.after = vm.items[vm.items.length-1].id
}
this.loading.items = true this.loading.items = true
return api.items.list(query).then(function(data) { api.items.list(query).then(function(data) {
vm.items = data.list if (loadMore) {
vm.itemsPage = data.page vm.items = vm.items.concat(data.list)
} else {
vm.items = data.list
}
vm.itemsHasMore = data.has_more
vm.loading.items = false 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) { loadMoreItems: function(event, el) {
if (this.itemsPage.cur >= this.itemsPage.num) return if (!this.itemsHasMore) return
if (this.loading.items) return if (this.loading.items) return
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50 if (this.itemListCloseToBottom()) this.refreshItems(true)
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
})
}
}, },
markItemsRead: function() { markItemsRead: function() {
var query = this.getItemsQuery() var query = this.getItemsQuery()
@@ -371,6 +457,7 @@ var vm = new Vue({
vm.items = [] vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1} vm.itemsPage = {'cur': 1, 'num': 1}
vm.itemSelected = null vm.itemSelected = null
vm.itemsHasMore = false
vm.refreshStats() vm.refreshStats()
}) })
}, },
@@ -389,6 +476,7 @@ var vm = new Vue({
var folder_id = folder ? folder.id : null var folder_id = folder ? folder.id : null
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() { api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {
feed.folder_id = folder_id feed.folder_id = folder_id
vm.refreshStats()
}) })
}, },
moveFeedToNewFolder: function(feed) { moveFeedToNewFolder: function(feed) {
@@ -396,7 +484,9 @@ var vm = new Vue({
if (!title) return if (!title) return
api.folders.create({'title': title}).then(function(folder) { api.folders.create({'title': title}).then(function(folder) {
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() { api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
vm.refreshFeeds() vm.refreshFeeds().then(function() {
vm.refreshStats()
})
}) })
}) })
}, },
@@ -418,21 +508,29 @@ var vm = new Vue({
if (newTitle) { if (newTitle) {
api.folders.update(folder.id, {title: newTitle}).then(function() { api.folders.update(folder.id, {title: newTitle}).then(function() {
folder.title = newTitle folder.title = newTitle
}) this.folders.sort(function(a, b) {
return a.title.localeCompare(b.title)
})
}.bind(this))
} }
}, },
deleteFolder: function(folder) { deleteFolder: function(folder) {
if (confirm('Are you sure you want to delete ' + folder.title + '?')) { if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
api.folders.delete(folder.id).then(function() { api.folders.delete(folder.id).then(function() {
if (vm.feedSelected === 'folder:'+folder.id) { vm.feedSelected = null
vm.items = []
vm.feedSelected = ''
}
vm.refreshStats() vm.refreshStats()
vm.refreshFeeds() vm.refreshFeeds()
}) })
} }
}, },
updateFeedLink: function(feed) {
var newLink = prompt('Enter feed link', feed.feed_link)
if (newLink) {
api.feeds.update(feed.id, {feed_link: newLink}).then(function() {
feed.feed_link = newLink
})
}
},
renameFeed: function(feed) { renameFeed: function(feed) {
var newTitle = prompt('Enter new title', feed.title) var newTitle = prompt('Enter new title', feed.title)
if (newTitle) { if (newTitle) {
@@ -444,12 +542,7 @@ var vm = new Vue({
deleteFeed: function(feed) { deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) { if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
api.feeds.delete(feed.id).then(function() { api.feeds.delete(feed.id).then(function() {
// unselect feed to prevent reading properties of null in template vm.feedSelected = null
var isSelected = !vm.feedSelected
|| (vm.feedSelected === 'feed:'+feed.id
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
if (isSelected) vm.feedSelected = null
vm.refreshStats() vm.refreshStats()
vm.refreshFeeds() vm.refreshFeeds()
}) })
@@ -469,7 +562,8 @@ var vm = new Vue({
if (result.status === 'success') { if (result.status === 'success') {
vm.refreshFeeds() vm.refreshFeeds()
vm.refreshStats() vm.refreshStats()
vm.$bvModal.hide('settings-modal') vm.settings = ''
vm.feedSelected = 'feed:' + result.feed.id
} else if (result.status === 'multiple') { } else if (result.status === 'multiple') {
vm.feedNewChoice = result.choice vm.feedNewChoice = result.choice
vm.feedNewChoiceSelected = result.choice[0].url vm.feedNewChoiceSelected = result.choice[0].url
@@ -479,25 +573,30 @@ var vm = new Vue({
vm.loading.newfeed = false 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) { toggleItemStarred: function(item) {
if (item.status == 'starred') { this.toggleItemStatus(item, 'starred', 'read')
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})
}, },
toggleItemRead: function(item) { toggleItemRead: function(item) {
if (item.status == 'unread') { this.toggleItemStatus(item, 'unread', 'read')
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})
}, },
importOPML: function(event) { importOPML: function(event) {
var input = event.target var input = event.target
@@ -509,33 +608,32 @@ var vm = new Vue({
vm.refreshStats() vm.refreshStats()
}) })
}, },
getReadable: function(item) { logout: function() {
api.logout().then(function() {
document.location.reload()
})
},
toggleReadability: function() {
if (this.itemSelectedReadability) { if (this.itemSelectedReadability) {
this.itemSelectedReadability = null this.itemSelectedReadability = null
return return
} }
var item = this.itemSelectedDetails
if (!item) return
if (item.link) { if (item.link) {
this.loading.readability = true 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 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) { showSettings: function(settings) {
this.settings = settings this.settings = settings
this.$bvModal.show('settings-modal')
if (settings === 'manage') { if (settings === 'create') {
api.feeds.list_errors().then(function(errors) { vm.feedNewChoice = []
vm.feed_errors = errors vm.feedNewChoiceSelected = ''
})
} }
}, },
resizeFeedList: function(width) { resizeFeedList: function(width) {
@@ -552,7 +650,10 @@ var vm = new Vue({
this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1) this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1)
}, },
fetchAllFeeds: function() { fetchAllFeeds: function() {
api.feeds.refresh().then(this.refreshStats.bind(this)) if (this.loading.feeds) return
api.feeds.refresh().then(function() {
vm.refreshStats()
})
}, },
computeStats: function() { computeStats: function() {
var filter = this.filterSelected var filter = this.filterSelected
@@ -585,16 +686,4 @@ var vm = new Vue({
} }
}) })
api.settings.get().then(function(data) { vm.$mount('#app')
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.refreshRate = data.refresh_rate
vm.refreshItems()
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()
}
})

View File

@@ -5,7 +5,8 @@
<title>yarr!</title> <title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css"> <link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css"> <link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/anchor.png"> <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"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style> <style>
form { form {
@@ -21,16 +22,20 @@
} }
</style> </style>
</head> </head>
<body> <body class="theme-{% .settings.theme_name %}">
<form action="" method="post"> <form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt=""> <img src="./static/graphicarts/anchor.svg" alt="">
{% if .error %}
<div class="text-danger text-center my-3">{% .error %}</div>
{% end %}
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input name="username" class="form-control" id="username" autocomplete="off"> <input name="username" class="form-control" id="username" autocomplete="off"
value="{% if .username %}{% .username %}{% end %}" required autofocus>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<input name="password" class="form-control" id="password" type="password"> <input name="password" class="form-control" id="password" type="password" required>
</div> </div>
<button class="btn btn-block btn-default" type="submit">Login</button> <button class="btn btn-block btn-default" type="submit">Login</button>
</form> </form>

View File

@@ -2,18 +2,22 @@
display: none !important; display: none !important;
} }
body { html {
font-size: 15px !important; font-size: 15px !important;
} }
body {
overscroll-behavior: none;
}
/* bootstrap customizations */ /* bootstrap customizations */
.btn-link { .btn-link {
color: inherit; color: inherit;
} }
.form-control { .form-control, .form-control:focus {
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07); box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07) !important;
} }
select.form-control { select.form-control {
@@ -22,12 +26,11 @@ select.form-control {
select.form-control:not([multiple]):not([size]) { select.form-control:not([multiple]):not([size]) {
box-shadow: 0 1px 2px rgba(0,0,0,0.1); 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; padding-right: 1.2rem;
cursor: pointer; cursor: pointer;
} }
.form-control:focus, .btn:focus { .btn:focus {
box-shadow: none !important; box-shadow: none !important;
} }
@@ -43,26 +46,25 @@ select.form-control:not([multiple]):not([size]) {
display: none; display: none;
} }
#settings-modal {
color: #212529 !important;
}
.settings-dropdown .dropdown-toggle { .settings-dropdown .dropdown-toggle {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }
.dropdown-menu { .settings-dropdown .dropdown-menu {
padding: 0; padding: 0;
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.07); box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.07);
overflow: hidden; 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; padding: .375rem 1rem;
} }
.dropdown-divider { .settings-dropdown .dropdown-divider {
margin: 0; margin: 0;
} }
@@ -70,28 +72,15 @@ select.form-control:not([multiple]):not([size]) {
outline: none; outline: none;
} }
.settings-dropdown .dropdown-item {
cursor: pointer;
}
.settings-dropdown .dropdown-item:focus { .settings-dropdown .dropdown-item:focus {
outline: none; outline: none;
} }
.settings-dropdown.large .dropdown-item { .settings-dropdown form:focus {
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 {
outline: none; outline: none;
} }
@@ -99,17 +88,28 @@ select.form-control:not([multiple]):not([size]) {
outline: none; outline: none;
} }
.b-tooltip { .table-compact {
opacity: 1; color: unset !important;
font-size: .7rem;
} }
.b-tooltip:focus { .table-compact tr td:first-child {
outline: none; padding-left: 0;
}
.table-compact tr td:last-child {
padding-right: 0;
} }
/* custom elements */ /* custom elements */
.font-serif {
font-family: Georgia, serif;
}
.font-monospace {
font-family: SFMono-Regular, Menlo, Consolas, monospace;
}
.icon { .icon {
height: 1rem; height: 1rem;
width: 1rem; width: 1rem;
@@ -167,7 +167,9 @@ select.form-control:not([multiple]):not([size]) {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
z-index: -1; z-index: -1;
top: 0; left: 0; top: 0;
left: 0;
height: 100%;
} }
.selectgroup + .selectgroup { .selectgroup + .selectgroup {
@@ -184,7 +186,6 @@ select.form-control:not([multiple]):not([size]) {
cursor: pointer; cursor: pointer;
} }
.list-row:hover,
.toolbar-item:hover, .toolbar-item:hover,
.toolbar-search:hover, .toolbar-search:hover,
.selectgroup-label:hover, .selectgroup-label:hover,
@@ -240,7 +241,6 @@ select.form-control:not([multiple]):not([size]) {
border: 1px solid #ced4da; border: 1px solid #ced4da;
border-radius: .25rem; border-radius: .25rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.1); box-shadow: 0 1px 2px rgba(0,0,0,0.1);
background: linear-gradient(#fff, #f5f7f9);
} }
.btn-default:active { .btn-default:active {
@@ -257,16 +257,6 @@ select.form-control:not([multiple]):not([size]) {
background-color: #f8f9fa; 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 { .toolbar {
min-height: 2rem !important; min-height: 2rem !important;
max-height: 2rem !important; max-height: 2rem !important;
@@ -349,57 +339,18 @@ select.form-control:not([multiple]):not([size]) {
outline: none; 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 { #opml-import-form input[type="file"]::-webkit-file-upload-button {
position: absolute; position: absolute;
top: -999px; top: -999px;
left: -999px; left: -999px;
} }
.custom-modal {
background-color: rgba(0, 0, 0, 0.9);
overflow-y: auto;
display: block !important;
}
/* content */ /* content */
.content { .content {
@@ -407,14 +358,51 @@ select.form-control:not([multiple]):not([size]) {
line-height: 1.5; line-height: 1.5;
} }
.content-wrapper {
max-width: 60rem;
margin: 0 auto;
}
.content img, .content video { .content img, .content video {
max-width: 100%; max-width: 100%;
height: auto; 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 { .content pre {
overflow-x: auto;
color: inherit; color: inherit;
overflow-x: scroll; border: 1px solid #dee2e6;
border-radius: 3px;
margin-left: -0.5rem;
margin-right: -0.5rem;
padding: 0.5rem;
} }
.content a { .content a {
@@ -442,14 +430,23 @@ select.form-control:not([multiple]):not([size]) {
font-size: 1rem; font-size: 1rem;
} }
.content p {
margin-top: 1rem;
margin-bottom: 1rem;
}
/* theme: light */ /* theme: light */
button.theme-light {
background-color: #fff !important;
}
a, a,
.btn-link:hover, .btn-link:hover {
.toolbar-item.active {
color: #0080d4; color: #0080d4;
} }
.toolbar-item.active,
.dropdown-item.active, .dropdown-item.active,
.dropdown-item:active, .dropdown-item:active,
.selectgroup input:checked + .selectgroup-label { .selectgroup input:checked + .selectgroup-label {
@@ -464,45 +461,60 @@ a,
/* theme: sepia */ /* theme: sepia */
.themepicker input[value=sepia] + .themepicker-label,
.theme-sepia, .theme-sepia,
.theme-sepia .btn-default,
.theme-sepia .dropdown-menu,
.theme-sepia .form-control,
.theme-sepia .modal-content,
.theme-sepia .toolbar-search { .theme-sepia .toolbar-search {
background-color: #f4f0e5; background-color: #f4f0e5 !important;
} }
.theme-sepia .content hr, .theme-sepia .content hr,
.theme-sepia .content pre,
.theme-sepia .border-right, .theme-sepia .border-right,
.theme-sepia .border-top { .theme-sepia .border-top {
border-color: #e0d6ba !important; 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-item:hover,
.theme-sepia .toolbar-search:hover, .theme-sepia .toolbar-search:hover,
.theme-sepia .dropdown-item:hover,
.theme-sepia .toolbar-search:focus { .theme-sepia .toolbar-search:focus {
background-color: #e0d6ba; background-color: #e0d6ba;
} }
/* theme: night */ /* theme: night */
.themepicker input[value=night] + .themepicker-label,
.theme-night, .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 { .theme-night .toolbar-search {
color: #d1d1d1; color: #d1d1d1;
background-color: #0e0e0e; background-color: #0e0e0e;
} }
.theme-night .content hr, .theme-night .content hr,
.theme-night .content pre,
.theme-night .border-right, .theme-night .border-right,
.theme-night .border-top { .theme-night .border-top,
.theme-night .dropdown-divider {
border-color: #1a1a1a !important; border-color: #1a1a1a !important;
} }
.theme-night .selectgroup-label:hover,
.theme-night .selectgroup-label:not(.appearance-option):hover, .theme-night .dropdown-item:hover,
.theme-night .toolbar-item:hover, .theme-night .toolbar-item:hover,
.theme-night .toolbar-search:hover, .theme-night .toolbar-search:hover,
.theme-night .toolbar-search:focus { .theme-night .toolbar-search:focus {
background-color: #1a1a1a; background-color: #1a1a1a;
} }
.theme-night .dropdown-menu,
.theme-night .modal-content {
border-color: #1a1a1a;
}
/* animation */ /* animation */
.indicator-enter-active, .indicator-leave-active { .indicator-enter-active, .indicator-leave-active {
transition: all .3s; transition: all .3s;

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()
}
}

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