450 Commits

Author SHA1 Message Date
nkanaev
29d9ec6ef1 update workflow 2025-03-21 18:43:19 +00:00
nkanaev
d2224399e2 update build workflow 2025-03-21 18:40:40 +00:00
nkanaev
67fbed7f6b update build workflow 2025-03-21 18:34:12 +00:00
nkanaev
c1df3f8068 update release step 2025-03-21 16:27:52 +00:00
nkanaev
0aed9b51a9 reorganise build workflow 2025-03-21 12:02:06 +00:00
nkanaev
0bd7a66086 reorganise build workflow 2025-03-21 11:45:06 +00:00
nkanaev
2b6823a277 remove cache between builds 2025-03-21 11:20:21 +00:00
nkanaev
dd7ed84a6c disable windows_amd64_gui temporarily 2025-03-21 10:10:16 +00:00
nkanaev
2c6a5ca971 fix windows file extension 2025-03-21 08:28:53 +00:00
nkanaev
5bf7647cba fix makefile 2025-03-21 00:42:37 +00:00
nkanaev
f721034ae5 disable windows_arm64_gui temporarily 2025-03-21 00:40:10 +00:00
nkanaev
a32361fab2 update build doc & dockerfiles 2025-03-21 00:38:32 +00:00
nkanaev
572e489db6 oopsies 2025-03-21 00:07:15 +00:00
nkanaev
efcb6f8bf0 fix github action input parameters 2025-03-21 00:02:03 +00:00
nkanaev
7e367ef537 provide missing shell param to github action 2025-03-20 23:59:30 +00:00
nkanaev
b9a3326a98 fix github action dir 2025-03-20 23:53:47 +00:00
nkanaev
484b155a3c fix workflow yaml syntax 2025-03-20 23:51:31 +00:00
nkanaev
9cba4e8deb fix workflow composite action 2025-03-20 23:49:36 +00:00
nkanaev
749d7b682e update build workflow 2025-03-20 23:44:36 +00:00
nkanaev
35850d6310 makefile: armv7 build 2025-03-19 22:59:25 +00:00
nkanaev
15db17d834 update makefile: windows 2025-03-19 11:49:39 +00:00
nkanaev
a0d86e884a cli builds 2025-03-18 22:46:18 +00:00
nkanaev
acf97c8a3b update makefile 2025-03-18 17:23:19 +00:00
nkanaev
34bf9e5160 rewrite macos build 2025-03-18 17:14:58 +00:00
nkanaev
4420f3a8ae github: cleanup workflow yaml 2025-03-12 23:04:37 +00:00
dependabot[bot]
8d2ea6cf8a Bump golang.org/x/net from 0.33.0 to 0.36.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-12 22:51:51 +00:00
nkanaev
e244237474 github: set workflow trigger conditions 2025-03-12 22:48:38 +00:00
nkanaev
ff81c9d689 github: run build after test 2025-03-12 22:48:38 +00:00
nkanaev
11d99f106e github: test workflow 2025-03-12 22:48:38 +00:00
Nazar Kanaev
b8afa82a81 support multiple media links 2025-03-11 11:15:09 +00:00
nkanaev
097a2da5cb go fmt 2025-03-04 17:05:41 +00:00
nkanaev
e6d32946c1 add aria-pressed tag for the corresponding UI elements 2025-03-04 16:51:30 +00:00
nkanaev
fe4eaa4b8d fix start_url for manifest.json 2025-03-04 14:41:23 +00:00
nkanaev
48a671b285 add header accessibility tags 2025-03-04 14:12:46 +00:00
nkanaev
011c9c7668 add theme toggle button labels 2025-03-04 14:08:42 +00:00
nkanaev
f06fc1f750 add systray icon tooltip 2025-03-04 14:03:52 +00:00
dependabot[bot]
0e88d4284d Bump golang.org/x/net from 0.23.0 to 0.33.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.33.0.
- [Commits](https://github.com/golang/net/compare/v0.23.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-04 13:15:37 +00:00
nkanaev
1615c6869f fix tests 2025-03-04 13:14:54 +00:00
Andre Heider
800f43b299 upload artifacts to push actions too 2025-03-04 12:46:07 +00:00
Andre Heider
15bff0a0c4 add linux arm and arm64 builders 2025-03-04 12:46:07 +00:00
Andre Heider
e1481f4aac don't use deprecated github actions 2025-03-04 12:46:07 +00:00
Andre Heider
7ef97ee6db offer common youtube playlists as feed 2025-03-04 12:37:27 +00:00
Nadia Santalla
d785fe4c5a Dockerfile: cache go build directories across builds 2025-03-04 12:22:17 +00:00
Nazar Kanaev
5254df53dc update docs 2024-12-04 21:44:10 +00:00
tillcash
7301eab99c Add Dynamic Disable Functionality to Navigation Buttons 2024-12-04 21:42:36 +00:00
Nazar Kanaev
ad138c3017 show article content in the list if the title is missing 2024-12-04 21:36:44 +00:00
Nazar Kanaev
b09c95d7ea navigation buttons (+ fix keyboard navigation in 1-pane view) 2024-12-04 15:57:34 +00:00
Nazar Kanaev
64611a0dd3 update changelog 2024-12-04 14:28:10 +00:00
Donovan Glover
321ad7608f fix: use noreferrer for external links
Based on the existing noreferrer code in the code base.
2024-11-28 12:58:00 +00:00
Nazar Kanaev
2a8b6ea935 update changelog 2024-11-25 21:50:38 +00:00
Nazar Kanaev
e9cbea500b autogenerate feed item guids 2024-11-25 21:35:04 +00:00
Nazar Kanaev
223039b2c6 do not send referrer for external images 2024-11-25 21:01:45 +00:00
funkycode
7402dfc4e6 fix the build 2024-11-09 23:10:43 +00:00
Tim Shaffer
6b12715506 Only read config dir if db is not provided. 2024-11-01 10:27:59 +00:00
Aidan Holm
2dc58c5c8e Fix user agent using hardcoded 1.0 version. 2024-10-30 09:49:33 +00:00
Nazar Kanaev
0cef51c6ac add sample links 2024-10-07 22:22:56 +01:00
Nazar Kanaev
2a4d974965 go fmt 2024-10-07 12:20:45 +01:00
dependabot[bot]
f71792d6a5 Bump actions/download-artifact from 2 to 4.1.7 in /.github/workflows
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 2 to 4.1.7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v2...v4.1.7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-29 21:36:53 +01:00
Nazar Kanaev
b571042c5d change item table index 2024-06-21 12:29:09 +01:00
Nazar Kanaev
349c966c63 sqlite parameters 2024-06-21 10:56:48 +01:00
Nazar Kanaev
4a42b239cc update changelog 2024-06-16 11:39:23 +01:00
Karol Kosek
b9b3d2350c atom: Stop unescaping special HTML characters
The HTML data in Atom is escaped because the data needs to put as a
string to an XML file. If we are accessing it by reading the string
value, then it is already unescaped, as opposed to getting the raw
XML data.

XHTML data don't need to be unescaped either since the elements are
already encoded as is in tree. :)

Closes #198
2024-06-16 11:35:32 +01:00
nkanaev
b13cd85f0b update makefile 2024-05-14 13:22:14 +01:00
Jason Rogena
daffd721eb Allow overriding the GOARCH in the makefile
To allow for building yarr for other architectures, make the GOARCH
env var overridable in the makefile.

Signed-off-by: Jason Rogena <null+github.com@rogena.me>
2024-05-12 22:57:18 +01:00
dependabot[bot]
24232d72e9 Bump golang.org/x/net from 0.17.0 to 0.23.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.17.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.17.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 14:03:30 +01:00
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
302 changed files with 465190 additions and 3304 deletions

25
.github/actions/prepare/action.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Build & Upload
inputs:
id:
description: artifact name
required: true
cmd:
description: command to run
required: true
out:
description: path to output file
required: true
runs:
using: composite
steps:
- name: compile
run: ${{ inputs.cmd }}
shell: bash
- name: archive
run: tar -cvf ${{ inputs.out }}.tar ${{ inputs.out }}
shell: bash
- name: upload
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.id }}
path: ${{ inputs.out }}.tar

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

@@ -1,144 +1,143 @@
name: build name: Build
on: on:
push: push:
tags: ['v*', 'test*'] tags:
- v*
workflow_dispatch:
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" - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with: with:
submodules: 'recursive' go-version: '^1.18'
- name: "Setup Go" - name: Build arm64 gui
uses: actions/setup-go@v2 uses: ./.github/actions/prepare
with: with:
go-version: '^1.16' id: darwin_arm64_gui
- name: Cache Go Modules cmd: make darwin_arm64_gui
uses: actions/cache@v2 out: out/darwin_arm64_gui/yarr.app
- name: Build amd64 gui
uses: ./.github/actions/prepare
with: with:
path: ~/go/pkg/mod id: darwin_amd64_gui
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} cmd: make darwin_amd64_gui
restore-keys: | out: out/darwin_amd64_gui/yarr.app
${{ runner.os }}-go- - name: Build arm64 cli
- name: "Build" uses: ./.github/actions/prepare
run: make build_macos
- name: Upload
uses: actions/upload-artifact@v2
with: with:
name: macos id: darwin_arm64
path: _output/macos/yarr.app cmd: make darwin_arm64
out: out/darwin_arm64/yarr
- name: Build amd64 cli
uses: ./.github/actions/prepare
with:
id: darwin_amd64
cmd: make darwin_amd64
out: out/darwin_amd64/yarr
build_windows: build_windows:
name: Build for Windows name: Build for Windows
runs-on: windows-2019 runs-on: windows-2022
steps: steps:
- name: "Checkout" - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with: with:
submodules: 'recursive' go-version: '^1.18'
- name: "Setup Go" - name: Build amd64 gui
uses: actions/setup-go@v2 uses: ./.github/actions/prepare
with: with:
go-version: '^1.16' id: windows_amd64_gui
- name: Cache Go Modules cmd: make windows_amd64_gui
uses: actions/cache@v2 out: out/windows_amd64_gui/yarr.exe
- name: Build arm64 gui
if: false
uses: ./.github/actions/prepare
with: with:
path: ~/go/pkg/mod id: windows_arm64_gui
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} cmd: make windows_arm64_gui
restore-keys: | out: out/windows_arm64_gui/yarr.exe
${{ runner.os }}-go-
- name: "Build"
run: make build_windows
- name: Upload
uses: actions/upload-artifact@v2
with:
name: windows
path: _output/windows/yarr.exe
build_linux: build_multi_cli:
name: Build for Linux name: Build for Windows/MacOS/Linux CLI
runs-on: ubuntu-18.04 runs-on: ubuntu-22.04
steps: steps:
- name: "Checkout" - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with: with:
submodules: 'recursive' go-version: '^1.18'
- name: "Setup Go" - name: Setup Zig
uses: actions/setup-go@v2 uses: mlugg/setup-zig@v1
with: with:
go-version: '^1.16' version: 0.14.0
- name: Cache Go Modules - name: Build linux/amd64
uses: actions/cache@v2 uses: ./.github/actions/prepare
with: with:
path: ~/go/pkg/mod id: linux_amd64
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} cmd: make linux_amd64
restore-keys: | out: out/linux_amd64/yarr
${{ runner.os }}-go- - name: Build linux/arm64
- name: "Build" uses: ./.github/actions/prepare
run: make build_linux
- name: Upload
uses: actions/upload-artifact@v2
with: with:
name: linux id: linux_arm64
path: _output/linux/yarr cmd: make linux_arm64
out: out/linux_arm64/yarr
- name: Build linux/armv7
uses: ./.github/actions/prepare
with:
id: linux_armv7
cmd: make linux_armv7
out: out/linux_armv7/yarr
- name: Build windows/amd64
uses: ./.github/actions/prepare
with:
id: windows_amd64
cmd: make windows_amd64
out: out/windows_amd64/yarr
- name: Build windows/arm64
uses: ./.github/actions/prepare
with:
id: windows_arm64
cmd: make windows_arm64
out: out/windows_arm64/yarr
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_multi_cli]
needs: [build_macos, build_windows, build_linux]
steps: steps:
- name: Create Release
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
prerelease: true
- name: Download Artifacts - name: Download Artifacts
uses: actions/download-artifact@v2 uses: actions/download-artifact@v4.1.7
with: with:
path: . path: .
- name: Preparation - name: Preparation
run: | run: |
set -ex
ls -R ls -R
chmod u+x macos/Contents/MacOS/yarr for tarfile in `ls **/*.tar`; do
chmod u+x linux/yarr tar -xvf $tarfile
done
mv macos yarr.app && zip -r yarr-macos.zip yarr.app for dir in out/*; do
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe echo "Compressing: $dir"
mv linux/yarr . && zip yarr-linux.zip yarr (test -d "$dir" && cd $dir && zip -r ../yarr_`basename $dir`.zip *)
- name: Upload MacOS done
uses: actions/upload-release-asset@v1 ls out
- name: Upload Release
uses: softprops/action-gh-release@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} draft: true
asset_path: ./yarr-macos.zip prerelease: true
asset_name: yarr-${{ github.ref }}-macos64.zip files: |
asset_content_type: application/zip out/*.zip
- name: Upload Windows
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-windows.zip
asset_name: yarr-${{ github.ref }}-windows32.zip
asset_content_type: application/zip
- name: Upload Linux
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-linux.zip
asset_name: yarr-${{ github.ref }}-linux32.zip
asset_content_type: application/zip

19
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '^1.18'
- name: Run tests
run: make test

5
.gitignore vendored
View File

@@ -1,7 +1,8 @@
/server/assets.go
/gofeed
/_output /_output
/out
/yarr /yarr
*.db *.db
*.db-shm
*.db-wal
*.syso *.syso
versioninfo.rc versioninfo.rc

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "src/gofeed"]
path = src/gofeed
url = http://github.com/mmcdole/gofeed

View File

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

View File

@@ -1,99 +0,0 @@
package main
import (
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"os/exec"
"path"
"strconv"
"strings"
)
var plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>yarr</string>
<key>CFBundleDisplayName</key>
<string>yarr</string>
<key>CFBundleIdentifier</key>
<string>nkanaev.yarr</string>
<key>CFBundleVersion</key>
<string>VERSION</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>yarr</string>
<key>CFBundleIconFile</key>
<string>icon</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.news</string>
<key>NSHighResolutionCapable</key>
<string>True</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
</dict>
</plist>
`
func run(cmd ...string) {
fmt.Println(cmd)
err := exec.Command(cmd[0], cmd[1:]...).Run()
if err != nil {
log.Fatal(err)
}
}
func main() {
var version, outdir string
flag.StringVar(&version, "version", "0.0", "")
flag.StringVar(&outdir, "outdir", "", "")
flag.Parse()
outfile := "yarr"
binDir := path.Join(outdir, "yarr.app", "Contents/MacOS")
resDir := path.Join(outdir, "yarr.app", "Contents/Resources")
plistFile := path.Join(outdir, "yarr.app", "Contents/Info.plist")
pkginfoFile := path.Join(outdir, "yarr.app", "Contents/PkgInfo")
os.MkdirAll(binDir, 0700)
os.MkdirAll(resDir, 0700)
f, _ := ioutil.ReadFile(path.Join(outdir, outfile))
ioutil.WriteFile(path.Join(binDir, outfile), f, 0755)
ioutil.WriteFile(plistFile, []byte(strings.Replace(plist, "VERSION", version, 1)), 0644)
ioutil.WriteFile(pkginfoFile, []byte("APPL????"), 0644)
iconFile := path.Join(outdir, "icon.png")
iconsetDir := path.Join(outdir, "icon.iconset")
os.Mkdir(iconsetDir, 0700)
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
if res == 1024 || res == 64 {
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res / 2, res / 2)
}
cmd := []string {
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
iconFile, "--out", path.Join(iconsetDir, outfile),
}
run(cmd...)
}
icnsFile := path.Join(resDir, "icon.icns")
run("iconutil", "-c", "icns", iconsetDir, "-o", icnsFile)
}

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

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

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

@@ -0,0 +1,158 @@
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"
"github.com/nkanaev/yarr/src/worker"
)
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)
}
if db == "" {
configPath, err := os.UserConfigDir()
if err != nil {
log.Fatal("Failed to get config dir: ", err)
}
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
var err error
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)
}
worker.SetVersion(Version)
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)
}
})
}
}

56
doc/build.md Normal file
View File

@@ -0,0 +1,56 @@
## Compilation
Prerequisies:
* Go >= 1.18
* C Compiler (GCC / Clang / ...)
* Zig >= 0.14.0 (optional, for cross-compiling CLI versions)
* binutils (optional, for building Windows GUI version)
Get the source code:
git clone https://github.com/nkanaev/yarr.git
Compile:
# create cli for the host OS/architecture
make host # out/yarr
# create GUI, works only in the target OS
make windows_amd64_gui # out/windows_amd64_gui/yarr.exe
make windows_arm64_gui # out/windows_arm64_gui/yarr.exe
make darwin_arm64_gui # out/darwin_arm64_gui/yarr.app
make darwin_amd64_gui # out/darwin_amd64_gui/yarr.app
# create cli, cross-compiles within any OS/architecture
make linux_amd64
make linux_arm64
make linux_armv7
make windows_amd64
make windows_arm64
# ... 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,35 +0,0 @@
# v1.4 (2021-03-11)
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
- (new) show podcast audio
- (fix) deleting feeds
- (etc) minor ui tweaks & changes
# v1.3 (2021-02-18)
- (fix) log out functionality if authentication is set
- (fix) import opml if authentication is set
- (fix) login page if authentication is set (thanks to @einschmidt)
# v1.2 (2021-02-11)
- (new) autorefresh rate
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
- (new) show feed errors in feed management modal
- (new) `-open` flag for automatically opening the server url
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
- (new) `-auth-file` flag for authentication
- (new) `-cert-file` & `-key-file` flags for TLS
- (fix) wrapping long words in the ui to prevent vertical scroll
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
# v1.1 (2020-10-05)
- (new) responsive design
- (fix) server crash on favicon fetch timeout (reported by @minioin)
- (fix) handling byte order marks in feeds (reported by @ilaer)
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
# v1.0 (2020-09-24)
Initial Release

113
doc/changelog.txt Normal file
View File

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

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

68
doc/samples.yml Normal file
View File

@@ -0,0 +1,68 @@
- site: https://vimeo.com/channels/staffpicks/videos
feed: https://vimeo.com/channels/staffpicks/videos/rss
tags: [vimeo, image]
- site: https://www.youtube.com/@everyframeapainting/videos
feed: https://www.youtube.com/feeds/videos.xml?channel_id=UCjFqcJQXGZ6T6sxyFB-5i6A"
tags: [youtube, image]
- site: https://iwdrm.tumblr.com/
feed: https://iwdrm.tumblr.com/rss
tags: [tumblr, image]
- site: https://falseknees.tumblr.com/
feed: https://falseknees.tumblr.com/rss
tags: [tumblr, image]
- site: https://accidentallyquadratic.tumblr.com/
feed: https://accidentallyquadratic.tumblr.com/rss
info: text blog with code sections
tags: [tumblr, text, code]
- site: https://www.flickr.com/photos/maratsafin/
feed: https://www.flickr.com/services/feeds/photos_public.gne?id=59021497@N07&lang=en-us&format=atom
tags: [flickr, image]
- site: https://www.reddit.com/r/comics
feed: https://www.reddit.com/r/comics.rss
tags: [reddit, image]
- site: https://www.reddit.com/r/AITAH
feed: https://www.reddit.com/r/AITAH.rss
tags: [reddit, text]
- site: https://idothei.wordpress.com/
feed: https://idothei.wordpress.com/feed/
tags: [wordpress, text]
- site: https://www.vidarholen.net/contents/blog/
feed: https://www.vidarholen.net/contents/blog/?feed=rss2
tags: [wordpress, text]
- site: https://blog.posthaven.com/
feed: https://blog.posthaven.com/posts.atom
tags: [posthaven, text]
- site: https://medium.com/@dailynewsletter
feed: https://medium.com/feed/@dailynewsletter
tags: [medium, text]
- site: https://thereveal.substack.com/
feed: https://thereveal.substack.com/feed
tags: [substack, text]
- site: https://tema.livejournal.com/
feed: https://tema.livejournal.com/data/rss
tags: [livejournal, text]
- site: https://mametter.hatenablog.com/
feed: https://mametter.hatenablog.com/feed
tags: [hatena, text]
- site: https://juliepowell.blogspot.com/
feed: https://juliepowell.blogspot.com/feeds/posts/default
tags: [blogger, text]
- site: https://micro.blog/val
feed: https://micro.blog/posts/val
tags: [json, microblog]

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,4 +0,0 @@
- fix: loading items (by scrolling down) is glitching while feeds are refreshing
- ref: switch to the standard logger
- ref: drop goquery, switch to cascadia
- ref: organize "server" package using KonMari method

View File

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

14
etc/dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM golang:alpine3.18 AS build
RUN apk add build-base git
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/root/go/pkg \
make host
FROM alpine:latest
RUN apk add --no-cache ca-certificates && \
update-ca-certificates
COPY --from=build /src/out/yarr /usr/local/bin/yarr
EXPOSE 7070
ENTRYPOINT ["/usr/local/bin/yarr"]
CMD ["-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]

38
etc/dockerfile.arm Normal file
View File

@@ -0,0 +1,38 @@
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 \
make host && mv out/yarr /root/out/yarr.arm64
RUN env \
CC=arm-linux-gnueabihf-gcc \
CGO_ENABLED=1 \
GOOS=linux GOARCH=arm GOARM=7 \
make host && mv out/yarr /root/out/yarr.armv7
CMD ["/bin/bash"]

BIN
etc/icon.icns Normal file

Binary file not shown.

BIN
etc/icon_macos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

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

62
etc/macos_package.sh Executable file
View File

@@ -0,0 +1,62 @@
#/bin/sh
set -e
usage() {
echo "usage: $0 VERSION path/to/icon.icns path/to/binary output/dir"
}
if [ $# -eq 0 ]; then
usage
exit
fi
VERSION=$1
ICNFILE=$2
BINFILE=$3
OUTPATH=$4
mkdir -p $OUTPATH/yarr.app/Contents/MacOS
mkdir -p $OUTPATH/yarr.app/Contents/Resources
mv $BINFILE $OUTPATH/yarr.app/Contents/MacOS/yarr
cp $ICNFILE $OUTPATH/yarr.app/Contents/Resources/icon.icns
chmod u+x $OUTPATH/yarr.app/Contents/MacOS/yarr
echo -n 'APPL????' >$OUTPATH/yarr.app/Contents/PkgInfo
cat <<EOF >$OUTPATH/yarr.app/Contents/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>yarr</string>
<key>CFBundleDisplayName</key>
<string>yarr</string>
<key>CFBundleIdentifier</key>
<string>nkanaev.yarr</string>
<key>CFBundleVersion</key>
<string>$VERSION</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>yarr</string>
<key>CFBundleIconFile</key>
<string>icon</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.news</string>
<key>NSHighResolutionCapable</key>
<string>True</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
</dict>
</plist>
EOF

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

After

Width:  |  Height:  |  Size: 173 KiB

89
etc/windows_versioninfo.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
set -e
# Function to display usage information
usage() {
echo "Usage: $0 [-version VERSION] [-outfile FILENAME]"
echo " -version VERSION Set the version number (default: 0.0)"
echo " -outfile FILENAME Set the output file name (default: versioninfo.rc)"
echo ""
echo "This script generates a Windows resource file with version information."
exit 1
}
# Default values
version="0.0"
outfile="versioninfo.rc"
# Check if help is requested
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
usage
fi
if [ $# -eq 0 ]; then
usage
fi
# Parse command-line options
while [[ $# -gt 0 ]]; do
case $1 in
-version)
if [[ -z "$2" || "$2" == -* ]]; then
echo "Error: Missing value for -version parameter"
usage
fi
version="$2"
shift 2
;;
-outfile)
if [[ -z "$2" || "$2" == -* ]]; then
echo "Error: Missing value for -outfile parameter"
usage
fi
outfile="$2"
shift 2
;;
*)
echo "Error: Unknown parameter: $1"
usage
;;
esac
done
# Replace dots with commas for version_comma
version_comma="${version//./,}"
# Use a here document for the template with ENDFILE delimiter
cat <<ENDFILE > "$outfile"
1 VERSIONINFO
FILEVERSION $version_comma,0,0
PRODUCTVERSION $version_comma,0,0
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "080904E4"
BEGIN
VALUE "CompanyName", "Old MacDonald's Farm"
VALUE "FileDescription", "Yet another RSS reader"
VALUE "FileVersion", "$version"
VALUE "InternalName", "yarr"
VALUE "LegalCopyright", "nkanaev"
VALUE "OriginalFilename", "yarr.exe"
VALUE "ProductName", "yarr"
VALUE "ProductVersion", "$version"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x809, 1252
END
END
1 ICON "icon.ico"
ENDFILE
# Set the correct permissions
chmod 644 "$outfile"
echo "Generated $outfile with version $version"

12
go.mod
View File

@@ -1,13 +1,11 @@
module github.com/nkanaev/yarr module github.com/nkanaev/yarr
go 1.16 go 1.18
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.36.0
github.com/mattn/go-sqlite3 v1.14.0 golang.org/x/sys v0.30.0
github.com/mmcdole/gofeed v1.0.0
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
) )
replace github.com/mmcdole/gofeed => ./src/gofeed require golang.org/x/text v0.22.0 // indirect

70
go.sum
View File

@@ -1,62 +1,8 @@
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.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
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/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

105
makefile
View File

@@ -1,36 +1,89 @@
VERSION=1.4 VERSION=2.4
GITHASH=$(shell git rev-parse --short=8 HEAD) GITHASH=$(shell git rev-parse --short=8 HEAD)
CGO_ENABLED=1 GO_TAGS = sqlite_foreign_keys sqlite_json
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
GO_LDFLAGS = -s -w GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)' GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
build_default: export CGO_ENABLED=1
mkdir -p _output
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
build_macos: default: test host
set GOOS=darwin
set GOARCH=amd64
mkdir -p _output/macos
go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr src/main.go
cp src/platform/icon.png _output/macos/icon.png
go run bin/package_macos.go -outdir _output/macos -version "$(VERSION)"
build_linux: # platform-specific files
set GOOS=linux
set GOARCH=386
mkdir -p _output/linux
go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr src/main.go
build_windows: etc/icon.icns: etc/icon_macos.png
set GOOS=windows mkdir -p etc/icon.iconset
set GOARCH=386 sips -s format png --resampleWidth 1024 etc/icon_macos.png --out etc/icon.iconset/icon_512x512@2x.png
mkdir -p _output/windows sips -s format png --resampleWidth 512 etc/icon_macos.png --out etc/icon.iconset/icon_512x512.png
go run bin/generate_versioninfo.go -version "$(VERSION)" -outfile src/platform/versioninfo.rc sips -s format png --resampleWidth 256 etc/icon_macos.png --out etc/icon.iconset/icon_256x256.png
sips -s format png --resampleWidth 128 etc/icon_macos.png --out etc/icon.iconset/icon_128x128.png
sips -s format png --resampleWidth 64 etc/icon_macos.png --out etc/icon.iconset/icon_32x32@2x.png
sips -s format png --resampleWidth 32 etc/icon_macos.png --out etc/icon.iconset/icon_32x32.png
sips -s format png --resampleWidth 16 etc/icon_macos.png --out etc/icon.iconset/icon_16x16.png
iconutil -c icns etc/icon.iconset -o etc/icon.icns
src/platform/versioninfo.rc:
./etc/windows_versioninfo.sh -version "$(VERSION)" -outfile src/platform/versioninfo.rc
windres -i src/platform/versioninfo.rc -O coff -o src/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 src/main.go
# build targets
host:
go build $(GO_FLAGS) -o out/yarr ./cmd/yarr
darwin_amd64:
# cross-compilation not supported: CC="zig cc -target x86_64-macos-none"
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
darwin_arm64:
# cross-compilation not supported: CC="zig cc -target aarch64-macos-none"
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
linux_amd64:
CC="zig cc -target x86_64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=amd64 \
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
linux_arm64:
CC="zig cc -target aarch64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm64 \
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
linux_armv7:
CC="zig cc -target arm-linux-musleabihf -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm GOARM=7 \
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
windows_amd64:
CC="zig cc -target x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
windows_arm64:
CC="zig cc -target aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
darwin_arm64_gui: etc/icon.icns
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
darwin_amd64_gui: etc/icon.icns
GOOS=darwin GOARCH=amd64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
windows_amd64_gui: src/platform/versioninfo.rc
GOOS=windows GOARCH=amd64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
windows_arm64_gui: src/platform/versioninfo.rc
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
serve: serve:
go run -tags "sqlite_foreign_keys" src/main.go -db local.db go run $(GO_FLAGS) ./cmd/yarr -db local.db
test:
go test $(GO_FLAGS) ./...
.PHONY: \
host \
darwin_amd64 darwin_amd64_gui \
darwin_arm64 darwin_arm64_gui \
windows_amd64 windows_amd64_gui \
windows_arm64 windows_arm64_gui \
serve test

View File

@@ -3,72 +3,36 @@
**yarr** (yet another rss reader) is a web-based feed aggregator which can be used both **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. as a desktop application and a personal self-hosted server.
It is written in Go with the frontend in Vue.js. The storage is backed by SQLite. The app is a single binary with an embedded database (SQLite).
![screenshot](etc/promo.png) ![screenshot](etc/promo.png)
## usage ## usage
The latest prebuilt binaries for Linux/MacOS/Windows are available The latest prebuilt binaries for Linux/MacOS/Windows AMD64 are available
[here](https://github.com/nkanaev/yarr/releases/latest). [here](https://github.com/nkanaev/yarr/releases/latest). Installation instructions:
### macos * MacOS
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder. 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".
The binaries are not signed, because the author doesn't want to buy a certificate. * Windows
Apple hates cheapskate developers, therefore the OS will refuse to run the application.
To bypass these measures, you can run the command:
xattr -d com.apple.quarantine /Applications/yarr.app Download `yarr-*-windows64.zip`, unzip it, open `yarr.exe`, click the anchor system tray icon, select "Open".
### windows * Linux
Download `yarr-*-windows32.zip`, unzip it, place wherever you'd like to Download `yarr-*-linux64.zip`, unzip it, place `yarr` in `$HOME/.local/bin`
(`C:\Program Files` or Recycle Bin). Create a shortcut manually if you'd like to. and run [the script](etc/install-linux.sh).
Microsoft doesn't like cheapskate developers too, [macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
but might only gently warn you about that, which you can safely ignore.
### linux
The Linux version doesn't come with the desktop environment integration.
For easy access on DE it is recommended to create a desktop menu entry by
by following the steps below:
unzip -x yarr*.zip
sudo mv yarr /usr/local/bin/yarr
sudo nano /usr/local/share/applications/yarr.desktop
and pasting the content:
[Desktop Entry]
Name=yarr
Exec=/usr/local/bin/yarr -open
Icon=rss
Type=Application
Categories=Internet;
For self-hosting, see `yarr -h` for auth, tls & server configuration flags. For self-hosting, see `yarr -h` for auth, tls & server configuration flags.
## build See more:
Install `Go >= 1.16` and `gcc`. Get the source code: * [Building from source code](doc/build.md)
* [Fever API support](doc/fever.md)
git clone --recurse-submodules 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
# ... or start a dev server locally
make serve # starts a server at http://localhost:7070
# ... or build a docker image
docker build -t yarr .
## credits ## credits

View File

@@ -4,8 +4,9 @@ import (
"embed" "embed"
"html/template" "html/template"
"io" "io"
"io/ioutil"
"io/fs" "io/fs"
"io/ioutil"
"log"
"os" "os"
) )
@@ -23,15 +24,24 @@ func (afs assetsfs) Open(name string) (fs.File, error) {
return os.DirFS("src/assets").Open(name) return os.DirFS("src/assets").Open(name)
} }
func Render(path string, writer io.Writer, data interface{}) { func Template(path string) *template.Template {
var tmpl *template.Template var tmpl *template.Template
tmpl, found := FS.templates[path] tmpl, found := FS.templates[path]
if !found { if !found {
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{ tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
"inline": func(svg string) template.HTML { "inline": func(svg string) template.HTML {
svgfile, _ := FS.Open("graphicarts/" + svg) svgfile, err := FS.Open("graphicarts/" + svg)
content, _ := ioutil.ReadAll(svgfile) // should never happen
svgfile.Close() 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) return template.HTML(content)
}, },
}).ParseFS(FS, path)) }).ParseFS(FS, path))
@@ -39,6 +49,11 @@ func Render(path string, writer io.Writer, data interface{}) {
FS.templates[path] = tmpl FS.templates[path] = tmpl
} }
} }
return tmpl
}
func Render(path string, writer io.Writer, data interface{}) {
tmpl := Template(path)
tmpl.Execute(writer, data) tmpl.Execute(writer, data)
} }

View File

@@ -1,5 +1,3 @@
// +build release
package assets package assets
import "embed" import "embed"
@@ -11,5 +9,5 @@ import "embed"
var embedded embed.FS var embedded embed.FS
func init() { func init() {
FS.embedded = &embedded FS.embedded = &embedded
} }

Binary file not shown.

Before

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

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

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 +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-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: 269 B

After

Width:  |  Height:  |  Size: 356 B

View File

@@ -5,10 +5,17 @@
<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">
<link rel="manifest" href="./manifest.json" />
<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">
<script>
window.app = window.app || {}
window.app.settings = {% .settings %}
window.app.authenticated = {% .authenticated %}
</script>
</head> </head>
<body class="theme-light"> <body class="theme-{% .settings.theme_name %}">
<div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak> <div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak>
<!-- feed list --> <!-- 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'}"> <div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
@@ -18,93 +25,102 @@
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<button class="toolbar-item" <button class="toolbar-item"
:class="{active: filterSelected == 'unread'}" :class="{active: filterSelected == 'unread'}"
v-b-tooltip.hover.bottom="'Unread'" :aria-pressed="filterSelected == 'unread'"
title="Unread"
@click="filterSelected = 'unread'"> @click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span> <span class="icon">{% inline "circle-full.svg" %}</span>
</button> </button>
<button class="toolbar-item" <button class="toolbar-item"
:class="{active: filterSelected == 'starred'}" :class="{active: filterSelected == 'starred'}"
v-b-tooltip.hover.bottom="'Starred'" :aria-pressed="filterSelected == 'starred'"
title="Starred"
@click="filterSelected = 'starred'"> @click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span> <span class="icon">{% inline "star-full.svg" %}</span>
</button> </button>
<button class="toolbar-item" <button class="toolbar-item"
:class="{active: filterSelected == ''}" :class="{active: filterSelected == ''}"
v-b-tooltip.hover.bottom="'All'" :aria-pressed="filterSelected == ''"
title="All"
@click="filterSelected = ''"> @click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span> <span class="icon">{% inline "assorted.svg" %}</span>
</button> </button>
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<b-dropdown <dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
right no-caret lazy variant="link" <template v-slot:button>
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> <span class="icon">{% inline "more-horizontal.svg" %}</span>
</template> </template>
<b-dropdown-item-button @click="showSettings('create')">
<button class="dropdown-item" @click="showSettings('create')">
<span class="icon mr-1">{% inline "plus.svg" %}</span> <span class="icon mr-1">{% inline "plus.svg" %}</span>
New Feed New Feed
</b-dropdown-item-button> </button>
<b-dropdown-item-button @click.stop="showSettings('manage')"> <div class="dropdown-divider"></div>
<span class="icon mr-1">{% inline "list.svg" %}</span> <button class="dropdown-item" @click="fetchAllFeeds()">
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> <span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds Refresh Feeds
</b-dropdown-item-button> </button>
<b-dropdown-divider></b-dropdown-divider> <div class="dropdown-divider"></div>
<b-dropdown-header>Refresh</b-dropdown-header> <header class="dropdown-header" role="heading" aria-level="2">Theme</header>
<b-dropdown-item-button @click.stop="refreshRate = min" v-for="min in [0, 60]"> <div class="row text-center m-0">
<span class="icon mr-1" :class="{invisible: refreshRate != min}">{% inline "check.svg" %}</span> <button class="btn btn-link col-4 px-0 rounded-0"
<span v-if="min == 0">Manually</span> :class="'theme-'+t"
<span v-if="min == 60">Every hour</span> :aria-label="t"
</b-dropdown-item-button> :aria-pressed="theme.name == 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>
<b-dropdown-divider></b-dropdown-divider> <div class="dropdown-divider"></div>
<b-dropdown-header>Sort by</b-dropdown-header> <header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
<b-dropdown-item-button @click.stop="itemSortNewestFirst=true"> <div class="row text-center m-0">
<span class="icon mr-1" :class="{invisible: !itemSortNewestFirst}">{% inline "check.svg" %}</span> <button class="dropdown-item col-4 px-0" :aria-pressed="!refreshRate" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
Newest First <button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 10" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
</b-dropdown-item-button> <button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 30" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
<b-dropdown-item-button @click="itemSortNewestFirst=false"> <button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 60" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
<span class="icon mr-1" :class="{invisible: itemSortNewestFirst}">{% inline "check.svg" %}</span> <button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 120" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
Oldest First <button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 240" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
</b-dropdown-item-button> </div>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Subscriptions</b-dropdown-header> <div class="dropdown-divider"></div>
<b-dropdown-form id="opml-import-form" enctype="multipart/form-data">
<header class="dropdown-header" role="heading" aria-level="2">Show first</header>
<div class="d-flex text-center">
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
<input type="file" <input type="file"
id="opml-import" id="opml-import"
@change="importOPML" @change="importOPML"
name="opml" name="opml"
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;"> style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import"> <label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
<span class="icon mr-1">{% inline "download.svg" %}</span> <span class="icon mr-1">{% inline "download.svg" %}</span>
Import Import
</label> </label>
</b-dropdown-form> </form>
<b-dropdown-item href="./opml/export"> <a class="dropdown-item" href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span> <span class="icon mr-1">{% inline "upload.svg" %}</span>
Export Export
</b-dropdown-item> </a>
<b-dropdown-divider></b-dropdown-divider> <div class="dropdown-divider"></div>
<b-dropdown-item-button @click="showSettings('shortcuts')"> <button class="dropdown-item" @click="showSettings('shortcuts')">
<span class="icon mr-1">{% inline "help-circle.svg" %}</span> <span class="icon mr-1">{% inline "help-circle.svg" %}</span>
Shortcuts Shortcuts
</b-dropdown-item-button> </button>
<b-dropdown-divider v-if="authenticated"></b-dropdown-divider> <div class="dropdown-divider" v-if="authenticated"></div>
<b-dropdown-item-button v-if="authenticated" @click="logout()"> <button class="dropdown-item" v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span> <span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out Log out
</b-dropdown-item-button> </button>
</b-dropdown> </dropdown>
</div> </div>
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1"> <div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
<label class="selectgroup"> <label class="selectgroup">
@@ -120,9 +136,10 @@
<div v-for="folder in foldersWithFeeds"> <div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1" <label class="selectgroup mt-1"
:class="{'d-none': filterSelected :class="{'d-none': filterSelected
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
&& !filteredFolderStats[folder.id] && !filteredFolderStats[folder.id]
&& (!itemSelected || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}"> && (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected"> <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"> <div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2" <span class="icon mr-2"
:class="{expanded: folder.is_expanded}" :class="{expanded: folder.is_expanded}"
@@ -136,15 +153,21 @@
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}"> <div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup" <label class="selectgroup"
:class="{'d-none': filterSelected :class="{'d-none': filterSelected
&& !(current.feed.id == feed.id)
&& !filteredFeedStats[feed.id] && !filteredFeedStats[feed.id]
&& (!itemSelected || itemSelectedDetails.feed_id != feed.id)}" && (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
v-for="feed in folder.feeds"> v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected"> <input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100"> <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-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="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="flex-fill text-left text-truncate">{{ feed.title }}</span>
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</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> </div>
</label> </label>
</div> </div>
@@ -161,20 +184,93 @@
<div class="px-2 toolbar d-flex align-items-center"> <div class="px-2 toolbar d-flex align-items-center">
<button class="toolbar-item mr-2 d-block d-md-none" <button class="toolbar-item mr-2 d-block d-md-none"
@click="feedSelected = null" @click="feedSelected = null"
v-b-tooltip.hover.bottom="'Show Feeds'"> title="Show Feeds">
<span class="icon">{% inline "chevron-left.svg" %}</span> <span class="icon">{% inline "chevron-left.svg" %}</span>
</button> </button>
<div class="input-icon flex-grow-1"> <div class="input-icon flex-grow-1">
<span class="icon">{% inline "search.svg" %}</span> <span class="icon">{% inline "search.svg" %}</span>
<!-- id used by keybindings --> <!-- id used by keybindings -->
<input id="searchbar" class="d-block toolbar-search" type="" v-model="itemSearch"> <input id="searchbar" type="" class="d-block toolbar-search" v-model="itemSearch" @keydown.enter="$event.target.blur()">
</div> </div>
<button class="toolbar-item ml-2" <button class="toolbar-item ml-2"
@click="markItemsRead()" @click="markItemsRead()"
v-if="filterSelected == 'unread'" v-if="filterSelected == 'unread'"
v-b-tooltip.hover.bottom="'Mark All Read'"> title="Mark All Read">
<span class="icon">{% inline "check.svg" %}</span> <span class="icon">{% inline "check.svg" %}</span>
</button> </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" role="heading" aria-level="2">{{ current.feed.title }}</header>
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" 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" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" 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" role="heading" aria-level="2">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" role="heading" aria-level="2">{{ 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>
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist"> <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" <label v-for="item in items" :key="item.id"
@@ -187,117 +283,114 @@
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span> <span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition> </transition>
<small class="flex-fill text-truncate mr-1"> <small class="flex-fill text-truncate mr-1">
{{ feedsById[item.feed_id].title }} {{ (feedsById[item.feed_id] || {}).title }}
</small> </small>
<small class="flex-shrink-0"><relative-time :val="item.date"/></small> <small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
</div> </div>
<div>{{ item.title || 'untitled' }}</div> <div>{{ item.title || 'untitled' }}</div>
</div> </div>
</label> </label>
<button class="btn btn-link btn-block loading my-3" v-if="itemsPage.cur < itemsPage.num"></button> <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>
</div> </div>
<!-- item show --> <!-- item show -->
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;"> <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"> <div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
<button class="toolbar-item" <button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)" @click="toggleItemStarred(itemSelectedDetails)"
v-b-tooltip.hover.bottom="'Mark Starred'"> title="Mark Starred">
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span> <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> <span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
</button> </button>
<button class="toolbar-item" <button class="toolbar-item"
:disabled="itemSelectedDetails.status=='starred'" title="Mark Unread"
v-b-tooltip.hover.bottom="'Mark Unread'"
@click="toggleItemRead(itemSelectedDetails)"> @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-full.svg" %}</span>
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span> <span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
</button> </button>
<a class="toolbar-item" id="content-appearance" v-b-tooltip.hover.bottom="'Appearance'" tabindex="0"> <dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
<span class="icon">{% inline "sliders.svg" %}</span> <template v-slot:button>
</a> <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" <button class="toolbar-item"
:class="{active: itemSelectedReadability}" :class="{active: itemSelectedReadability}"
@click="getReadable(itemSelectedDetails)" @click="toggleReadability()"
v-b-tooltip.hover.bottom="'Read Here'"> title="Read Here">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span> <span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button> </button>
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" v-b-tooltip.hover.bottom="'Open Link'"> <a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="Open Link">
<span class="icon">{% inline "external-link.svg" %}</span> <span class="icon">{% inline "external-link.svg" %}</span>
</a> </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> <div class="flex-grow-1"></div>
<button class="toolbar-item" @click="itemSelected=null" v-b-tooltip.hover.bottom="'Close Article'"> <button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
<span class="icon">{% inline "chevron-right.svg" %}</span>
</button>
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
<span class="icon">{% inline "x.svg" %}</span> <span class="icon">{% inline "x.svg" %}</span>
</button> </button>
</div> </div>
<div v-if="itemSelected" <div v-if="itemSelectedDetails"
ref="content" ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto" class="content px-4 pt-3 pb-5 border-top overflow-auto"
:style="{'font-family': theme.font, 'font-size': theme.size + 'rem'}"> :class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
<h1><b>{{ itemSelectedDetails.title }}</b></h1> :style="{'font-size': theme.size + 'rem'}">
<div class="text-muted"> <div class="content-wrapper">
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div> <h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
<time>{{ formatDate(itemSelectedDetails.date) }}</time> <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">
<div v-if="contentImages.length">
<figure v-for="media in contentImages">
<img :src="media.url" loading="lazy">
<figcaption v-if="media.description" v-html="media.description"></figcaption>
</figure>
</div>
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
<video class="w-100" controls v-for="media in contentVideos" :src="media.url"></video>
</div>
<div v-html="itemSelectedContent"></div>
</div> </div>
<hr>
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
<div v-html="itemSelectedContent"></div>
</div> </div>
</div> </div>
<b-modal id="settings-modal" hide-header hide-footer lazy> <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="$bvModal.hide('settings-modal')"> <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> <span class="icon">{% inline "x.svg" %}</span>
</button> </button>
<div v-if="settings=='create'"> <div v-if="settings=='create'">
<p class="cursor-default"><b>New Feed</b></p> <p class="cursor-default"><b>New Feed</b></p>
<form action="" @submit.prevent="createFeed(event)" class="mt-4"> <form action="" @submit.prevent="createFeed(event)" class="mt-4">
<label for="feed-url">URL</label> <label for="feed-url">URL</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0"> <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"> <label for="feed-folder" class="mt-3 d-block">
Folder Folder
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a> <a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
</label> </label>
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder"> <select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
<option value="">---</option> <option value="">---</option>
<option :value="folder.id" v-for="folder in folders">{{ folder.title }}</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> </select>
<div class="mt-4" v-if="feedNewChoice.length"> <div class="mt-4" v-if="feedNewChoice.length">
<p class="mb-2"> <p class="mb-2">
@@ -315,144 +408,35 @@
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button> <button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
</form> </form>
</div> </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 v-else-if="settings=='shortcuts'"> <div v-else-if="settings=='shortcuts'">
<p class="cursor-default"><b>Keyboard Shortcuts</b></p> <p class="cursor-default"><b>Keyboard Shortcuts</b></p>
<table class="table table-borderless table-sm table-compact m-0"> <table class="table table-borderless table-sm table-compact m-0">
<tr> <tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
<td> <td>show unread / starred / all feeds</td></tr>
<kbd>1</kbd> / <tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
<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 colspan=2>&nbsp;</td></tr>
<tr> <tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
<td> <tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
<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 colspan=2>&nbsp;</td></tr>
<tr> <tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
<td><kbd>R</kbd></td> <tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
<td>mark all articles as read</td> <tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
</tr> <tr><td><kbd>o</kbd></td> <td>open link</td></tr>
<tr> <tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
<td> <tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
<kbd>r</kbd>
</td>
<td>toggle an article as read / unread</td>
</tr>
<tr>
<td><kbd>s</kbd></td>
<td>toggle an article as starred / unstarred</td>
</tr>
<tr>
<td><kbd>o</kbd></td>
<td>open an article's link</td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </modal>
</div> </div>
<!-- polyfill -->
<script src="./static/javascripts/fetch.umd.js"></script>
<script src="./static/javascripts/url-polyfill.min.js"></script>
<!-- external --> <!-- external -->
<script src="./static/javascripts/vue.min.js"></script> <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.min.js"></script>
<script src="./static/javascripts/purify.min.js"></script>
<!-- internal --> <!-- internal -->
<script src="./static/javascripts/api.js"></script> <script src="./static/javascripts/api.js"></script>
<script src="./static/javascripts/app.js"></script> <script src="./static/javascripts/app.js"></script>
<script src="./static/javascripts/keybindings.js"></script> <script src="./static/javascripts/key.js"></script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@@ -71,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)
}, },
@@ -102,9 +105,7 @@
return api('post', './logout') return api('post', './logout')
}, },
crawl: function(url) { crawl: function(url) {
return xfetch('./page?url=' + url).then(function(res) { return api('get', './page?url=' + encodeURIComponent(url)).then(json)
return res.text()
})
} }
} }
})() })()

View File

@@ -2,19 +2,26 @@
var TITLE = document.title var TITLE = document.title
function authenticated() { function scrollto(target, scroll) {
return /auth=.+/g.test(document.cookie) 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 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 +33,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 +41,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 +72,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 +204,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 +271,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,14 +296,20 @@ 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)
}, },
contentImages: function() {
if (!this.itemSelectedDetails) return []
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'image')
},
contentAudios: function() {
if (!this.itemSelectedDetails) return []
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'audio')
},
contentVideos: function() {
if (!this.itemSelectedDetails) return []
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video')
}
}, },
watch: { watch: {
'theme': { 'theme': {
@@ -245,13 +339,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 +357,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 +391,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 +402,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 +439,53 @@ var vm = new Vue({
vm.feeds = values[1] vm.feeds = values[1]
}) })
}, },
refreshItems: function() { refreshItems: function(loadMore = false) {
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) { return 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
if (el.scrollHeight === 0) return false // element is invisible (responsive design)
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()) return this.refreshItems(true)
if (closeToBottom) { if (this.itemSelected && this.itemSelected === this.items[this.items.length - 1].id) return this.refreshItems(true)
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 +493,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()
}) })
}, },
@@ -421,21 +544,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) {
@@ -447,12 +578,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()
}) })
@@ -472,7 +598,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
@@ -482,25 +609,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
@@ -517,33 +649,27 @@ var vm = new Vue({
document.location.reload() document.location.reload()
}) })
}, },
getReadable: function(item) { 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) {
@@ -560,7 +686,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
@@ -590,19 +719,66 @@ var vm = new Vue({
this.filteredFolderStats = statsFolders this.filteredFolderStats = statsFolders
this.filteredTotalStats = statsTotal this.filteredTotalStats = statsTotal
}, },
// navigation helper, navigate relative to selected item
navigateToItem: function(relativePosition) {
let vm = this
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)
vm.loadMoreItems()
})
},
// navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) {
let vm = this
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)
})
},
} }
}) })
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')
})

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

View File

@@ -0,0 +1,133 @@
var helperFunctions = {
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', 'noopener,noreferrer')
}
},
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(){
vm.navigateToItem(+1)
},
previousItem() {
vm.navigateToItem(-1)
},
nextFeed(){
vm.navigateToFeed(+1)
},
previousFeed() {
vm.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

@@ -1,164 +0,0 @@
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)
})
}
}
var shortcutFunctions = {
openItemLink: function() {
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
window.open(vm.itemSelectedDetails.link, '_blank')
}
},
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)
},
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,
"r": shortcutFunctions.toggleItemRead,
"R": shortcutFunctions.markAllRead,
"s": shortcutFunctions.toggleItemStarred,
"/": shortcutFunctions.focusSearch,
"j": shortcutFunctions.nextItem,
"k": shortcutFunctions.previousItem,
"l": shortcutFunctions.nextFeed,
"h": shortcutFunctions.previousFeed,
"1": shortcutFunctions.showUnread,
"2": shortcutFunctions.showStarred,
"3": 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) {
return
}
var keybindFunction = keybindings[event.key]
if (keybindFunction) {
event.preventDefault()
keybindFunction()
}
})

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

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,8 +2,11 @@
display: none !important; display: none !important;
} }
body { html {
font-size: 15px !important; font-size: 15px !important;
}
body {
overscroll-behavior: none; overscroll-behavior: none;
} }
@@ -13,8 +16,8 @@ body {
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 {
@@ -23,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;
} }
@@ -44,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;
} }
@@ -71,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;
} }
@@ -100,13 +88,8 @@ 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 {
outline: none;
} }
.table-compact tr td:first-child { .table-compact tr td:first-child {
@@ -119,6 +102,14 @@ select.form-control:not([multiple]):not([size]) {
/* 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;
@@ -176,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 {
@@ -193,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,
@@ -249,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 {
@@ -266,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;
@@ -358,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 {
@@ -416,11 +358,43 @@ 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; overflow-x: auto;
color: inherit; color: inherit;
@@ -456,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 {
@@ -478,10 +461,13 @@ 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 .content pre,
@@ -489,36 +475,46 @@ a,
.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 .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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
package htmlutil
import "testing"
func TestExtractText(t *testing.T) {
testcases := [][2]string{
{"hello", "<div>hello</div>"},
{"hello world", "<div>hello</div> world"},
{"helloworld", "<div>hello</div>world"},
{"hello world", "hello <div>world</div>"},
{"helloworld", "hello<div>world</div>"},
{"hello world!", "hello <div>world</div>!"},
{"hello world !", "hello <div> world\r\n </div>!"},
}
for _, testcase := range testcases {
want := testcase[0]
base := testcase[1]
have := ExtractText(base)
if want != have {
t.Logf("base: %#v\n", base)
t.Logf("want: %#v\n", want)
t.Logf("have: %#v\n", have)
t.Fail()
}
}
}
func TestTruncateText(t *testing.T) {
input := "Lorem ipsum — классический текст-«рыба»"
size := 30
want := "Lorem ipsum — классический ..."
have := TruncateText(input, size)
if want != have {
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
}
size = 1000
want = input
have = TruncateText(input, size)
if want != have {
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,453 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package sanitizer
import (
"bytes"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"github.com/nkanaev/yarr/src/content/htmlutil"
"golang.org/x/net/html"
)
var splitSrcsetRegex = regexp.MustCompile(`,\s+`)
// Sanitize returns safe HTML.
func Sanitize(baseURL, input string) string {
var buffer bytes.Buffer
var tagStack []string
var parentTag string
blacklistedTagDepth := 0
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
for {
if tokenizer.Next() == html.ErrorToken {
err := tokenizer.Err()
if err == io.EOF {
return buffer.String()
}
return ""
}
token := tokenizer.Token()
switch token.Type {
case html.TextToken:
if blacklistedTagDepth > 0 {
continue
}
// An iframe element never has fallback content.
// See https://www.w3.org/TR/2010/WD-html5-20101019/the-iframe-element.html#the-iframe-element
if parentTag == "iframe" {
continue
}
buffer.WriteString(html.EscapeString(token.Data))
case html.StartTagToken:
tagName := token.Data
parentTag = tagName
if isValidTag(tagName) {
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
if hasRequiredAttributes(tagName, attrNames) {
wrap := isVideoIframe(token)
if wrap {
buffer.WriteString(`<div class="video-wrapper">`)
}
if len(attrNames) > 0 {
buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
} else {
buffer.WriteString("<" + tagName + ">")
}
if tagName == "iframe" {
// autoclose iframes
buffer.WriteString("</iframe>")
if wrap {
buffer.WriteString("</div>")
}
} else {
tagStack = append(tagStack, tagName)
}
}
} else if isBlockedTag(tagName) {
blacklistedTagDepth++
}
case html.EndTagToken:
tagName := token.Data
// iframes are autoclosed. see above
if tagName == "iframe" {
continue
}
if isValidTag(tagName) && inList(tagName, tagStack) {
buffer.WriteString(fmt.Sprintf("</%s>", tagName))
} else if isBlockedTag(tagName) {
blacklistedTagDepth--
}
case html.SelfClosingTagToken:
tagName := token.Data
if isValidTag(tagName) {
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
if hasRequiredAttributes(tagName, attrNames) {
if len(attrNames) > 0 {
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
} else {
buffer.WriteString("<" + tagName + "/>")
}
}
}
}
}
}
func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([]string, string) {
var htmlAttrs, attrNames []string
for _, attribute := range attributes {
value := attribute.Val
if !isValidAttribute(tagName, attribute.Key) {
continue
}
if (tagName == "img" || tagName == "source") && attribute.Key == "srcset" {
value = sanitizeSrcsetAttr(baseURL, value)
}
if isExternalResourceAttribute(attribute.Key) {
if tagName == "iframe" {
if isValidIframeSource(baseURL, attribute.Val) {
value = attribute.Val
} else {
continue
}
} else if tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val) {
value = attribute.Val
} else {
value = htmlutil.AbsoluteUrl(value, baseURL)
if value == "" {
continue
}
if !hasValidURIScheme(value) || isBlockedResource(value) {
continue
}
}
}
attrNames = append(attrNames, attribute.Key)
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
}
extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
if len(extraAttrNames) > 0 {
attrNames = append(attrNames, extraAttrNames...)
htmlAttrs = append(htmlAttrs, extraHTMLAttributes...)
}
return attrNames, strings.Join(htmlAttrs, " ")
}
func getExtraAttributes(tagName string) ([]string, []string) {
switch tagName {
case "a":
return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
case "video", "audio":
return []string{"controls"}, []string{"controls"}
case "iframe":
return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups"`, `loading="lazy"`}
case "img":
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
default:
return nil, nil
}
}
func isValidTag(tagName string) bool {
x := allowedTags.has(tagName) || allowedSvgTags.has(tagName) || allowedSvgFilters.has(tagName)
//fmt.Println(tagName, x)
return x
}
func isValidAttribute(tagName, attributeName string) bool {
if attrs, ok := allowedAttrs[tagName]; ok {
return attrs.has(attributeName)
}
if allowedSvgTags.has(tagName) {
return allowedSvgAttrs.has(attributeName)
}
return false
}
func isExternalResourceAttribute(attribute string) bool {
switch attribute {
case "src", "href", "poster", "cite":
return true
default:
return false
}
}
func hasRequiredAttributes(tagName string, attributes []string) bool {
elements := make(map[string][]string)
elements["a"] = []string{"href"}
elements["iframe"] = []string{"src"}
elements["img"] = []string{"src"}
elements["source"] = []string{"src", "srcset"}
for element, attrs := range elements {
if tagName == element {
for _, attribute := range attributes {
for _, attr := range attrs {
if attr == attribute {
return true
}
}
}
return false
}
}
return true
}
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
func hasValidURIScheme(src string) bool {
scheme := strings.SplitN(src, ":", 2)[0]
return allowedURISchemes.has(scheme)
}
func isBlockedResource(src string) bool {
blacklist := []string{
"feedsportal.com",
"api.flattr.com",
"stats.wordpress.com",
"plus.google.com/share",
"twitter.com/share",
"feeds.feedburner.com",
}
for _, element := range blacklist {
if strings.Contains(src, element) {
return true
}
}
return false
}
func isValidIframeSource(baseURL, src string) bool {
whitelist := []string{
"bandcamp.com",
"cdn.embedly.com",
"invidio.us",
"player.bilibili.com",
"player.vimeo.com",
"soundcloud.com",
"vk.com",
"w.soundcloud.com",
"www.dailymotion.com",
"www.youtube-nocookie.com",
"www.youtube.com",
}
domain := htmlutil.URLDomain(src)
// allow iframe from same origin
if htmlutil.URLDomain(baseURL) == domain {
return true
}
for _, safeDomain := range whitelist {
if safeDomain == domain {
return true
}
}
return false
}
func getTagAllowList() map[string][]string {
whitelist := make(map[string][]string)
whitelist["img"] = []string{"alt", "title", "src", "srcset", "sizes"}
whitelist["picture"] = []string{}
whitelist["audio"] = []string{"src"}
whitelist["video"] = []string{"poster", "height", "width", "src"}
whitelist["source"] = []string{"src", "type", "srcset", "sizes", "media"}
whitelist["dt"] = []string{}
whitelist["dd"] = []string{}
whitelist["dl"] = []string{}
whitelist["table"] = []string{}
whitelist["caption"] = []string{}
whitelist["thead"] = []string{}
whitelist["tfooter"] = []string{}
whitelist["tr"] = []string{}
whitelist["td"] = []string{"rowspan", "colspan"}
whitelist["th"] = []string{"rowspan", "colspan"}
whitelist["h1"] = []string{}
whitelist["h2"] = []string{}
whitelist["h3"] = []string{}
whitelist["h4"] = []string{}
whitelist["h5"] = []string{}
whitelist["h6"] = []string{}
whitelist["strong"] = []string{}
whitelist["em"] = []string{}
whitelist["code"] = []string{}
whitelist["pre"] = []string{}
whitelist["blockquote"] = []string{}
whitelist["q"] = []string{"cite"}
whitelist["p"] = []string{}
whitelist["ul"] = []string{}
whitelist["li"] = []string{}
whitelist["ol"] = []string{}
whitelist["br"] = []string{}
whitelist["del"] = []string{}
whitelist["a"] = []string{"href", "title"}
whitelist["figure"] = []string{}
whitelist["figcaption"] = []string{}
whitelist["cite"] = []string{}
whitelist["time"] = []string{"datetime"}
whitelist["abbr"] = []string{"title"}
whitelist["acronym"] = []string{"title"}
whitelist["wbr"] = []string{}
whitelist["dfn"] = []string{}
whitelist["sub"] = []string{}
whitelist["sup"] = []string{}
whitelist["var"] = []string{}
whitelist["samp"] = []string{}
whitelist["s"] = []string{}
whitelist["del"] = []string{}
whitelist["ins"] = []string{}
whitelist["kbd"] = []string{}
whitelist["rp"] = []string{}
whitelist["rt"] = []string{}
whitelist["rtc"] = []string{}
whitelist["ruby"] = []string{}
whitelist["iframe"] = []string{"width", "height", "frameborder", "src", "allowfullscreen"}
return whitelist
}
func inList(needle string, haystack []string) bool {
for _, element := range haystack {
if element == needle {
return true
}
}
return false
}
func isBlockedTag(tagName string) bool {
blacklist := []string{
"noscript",
"script",
"style",
}
for _, element := range blacklist {
if element == tagName {
return true
}
}
return false
}
/*
One or more strings separated by commas, indicating possible image sources for the user agent to use.
Each string is composed of:
- A URL to an image
- Optionally, whitespace followed by one of:
- A width descriptor (a positive integer directly followed by w). The width descriptor is divided by the source size given in the sizes attribute to calculate the effective pixel density.
- A pixel density descriptor (a positive floating point number directly followed by x).
*/
func sanitizeSrcsetAttr(baseURL, value string) string {
var sanitizedSources []string
rawSources := splitSrcsetRegex.Split(value, -1)
for _, rawSource := range rawSources {
parts := strings.Split(strings.TrimSpace(rawSource), " ")
nbParts := len(parts)
if nbParts > 0 {
sanitizedSource := parts[0]
if !strings.HasPrefix(parts[0], "data:") {
sanitizedSource = htmlutil.AbsoluteUrl(parts[0], baseURL)
if sanitizedSource == "" {
continue
}
}
if nbParts == 2 && isValidWidthOrDensityDescriptor(parts[1]) {
sanitizedSource += " " + parts[1]
}
sanitizedSources = append(sanitizedSources, sanitizedSource)
}
}
return strings.Join(sanitizedSources, ", ")
}
func isValidWidthOrDensityDescriptor(value string) bool {
if value == "" {
return false
}
lastChar := value[len(value)-1:]
if lastChar != "w" && lastChar != "x" {
return false
}
_, err := strconv.ParseFloat(value[0:len(value)-1], 32)
return err == nil
}
func isValidDataAttribute(value string) bool {
var dataAttributeAllowList = []string{
"data:image/avif",
"data:image/apng",
"data:image/png",
"data:image/svg",
"data:image/svg+xml",
"data:image/jpg",
"data:image/jpeg",
"data:image/gif",
"data:image/webp",
}
for _, prefix := range dataAttributeAllowList {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func isVideoIframe(token html.Token) bool {
videoWhitelist := map[string]bool{
"player.bilibili.com": true,
"player.vimeo.com": true,
"www.dailymotion.com": true,
"www.youtube-nocookie.com": true,
"www.youtube.com": true,
}
if token.Data == "iframe" {
for _, attr := range token.Attr {
if attr.Key == "src" {
domain := htmlutil.URLDomain(attr.Val)
return videoWhitelist[domain]
}
}
}
return false
}

View File

@@ -0,0 +1,315 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package sanitizer
import "testing"
func TestValidInput(t *testing.T) {
input := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy">.</p>`
want := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy" referrerpolicy="no-referrer">.</p>`
have := Sanitize("http://example.org/", input)
if have != want {
t.Errorf("Wrong output: \nwant: %#v\nhave: %#v", want, have)
}
}
func TestImgWithTextDataURL(t *testing.T) {
input := `<img src="data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" alt="Example">`
expected := ``
output := Sanitize("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
}
}
func TestImgWithDataURL(t *testing.T) {
input := `<img src="data:image/gif;base64,test" alt="Example">`
want := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
have := Sanitize("http://example.org/", input)
if have != want {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
func TestImgWithSrcset(t *testing.T) {
input := `<img srcset="example-320w.jpg, example-480w.jpg 1.5x, example-640w.jpg 2x, example-640w.jpg 640w" src="example-640w.jpg" alt="Example">`
want := `<img srcset="http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w" src="http://example.org/example-640w.jpg" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
have := Sanitize("http://example.org/", input)
if have != want {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
func TestImgWithSrcsetAndDataURL(t *testing.T) {
input := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example">`
want := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
have := Sanitize("http://example.org/", input)
if have != want {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
func TestSourceWithSrcsetAndMedia(t *testing.T) {
input := `<picture><source media="(min-width: 800px)" srcset="elva-800w.jpg"></picture>`
expected := `<picture><source media="(min-width: 800px)" srcset="http://example.org/elva-800w.jpg"></picture>`
output := Sanitize("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
}
}
func TestMediumImgWithSrcset(t *testing.T) {
input := `<img alt="Image for post" class="t u v ef aj" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" width="2730" height="3407">`
want := `<img alt="Image for post" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" loading="lazy" referrerpolicy="no-referrer">`
have := Sanitize("http://example.org/", input)
if have != want {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
func TestSelfClosingTags(t *testing.T) {
input := `<p>This <br> is a <strong>text</strong><br/>.</p>`
output := Sanitize("http://example.org/", input)
if input != output {
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
}
}
func TestTable(t *testing.T) {
input := `<table><tr><th>A</th><th colspan="2">B</th></tr><tr><td>C</td><td>D</td><td>E</td></tr></table>`
output := Sanitize("http://example.org/", input)
if input != output {
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
}
}
func TestRelativeURL(t *testing.T) {
input := `This <a href="/test.html">link is relative</a> and this image: <img src="../folder/image.png"/>`
want := `This <a href="http://example.org/test.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a> and this image: <img src="http://example.org/folder/image.png" loading="lazy" referrerpolicy="no-referrer"/>`
have := Sanitize("http://example.org/", input)
if want != have {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
func TestProtocolRelativeURL(t *testing.T) {
input := `This <a href="//static.example.org/index.html">link is relative</a>.`
expected := `This <a href="http://static.example.org/index.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a>.`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestInvalidTag(t *testing.T) {
input := `<p>My invalid <wtf>tag</wtf>.</p>`
expected := `<p>My invalid tag.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestVideoTag(t *testing.T) {
input := `<p>My valid <video src="videofile.webm" autoplay poster="posterimage.jpg">fallback</video>.</p>`
expected := `<p>My valid <video src="http://example.org/videofile.webm" poster="http://example.org/posterimage.jpg" controls>fallback</video>.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestAudioAndSourceTag(t *testing.T) {
input := `<p>My music <audio controls="controls"><source src="foo.wav" type="audio/wav"></audio>.</p>`
expected := `<p>My music <audio controls><source src="http://example.org/foo.wav" type="audio/wav"></audio>.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestUnknownTag(t *testing.T) {
input := `<p>My invalid <unknown>tag</unknown>.</p>`
expected := `<p>My invalid tag.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestInvalidNestedTag(t *testing.T) {
input := `<p>My invalid <wtf>tag with some <em>valid</em> tag</wtf>.</p>`
expected := `<p>My invalid tag with some <em>valid</em> tag.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestValidIFrame(t *testing.T) {
input := `<iframe src="http://example.org/"></iframe>`
want := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
have := Sanitize("http://example.org/", input)
if want != have {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
func TestInvalidIFrame(t *testing.T) {
input := `<iframe src="http://example.org/"></iframe>`
expected := ``
output := Sanitize("http://example.com/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestIFrameWithChildElements(t *testing.T) {
input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
expected := `<div class="video-wrapper"><iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
output := Sanitize("http://example.com/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestInvalidURLScheme(t *testing.T) {
input := `<p>This link is <a src="file:///etc/passwd">not valid</a></p>`
expected := `<p>This link is not valid</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestMailtoURIScheme(t *testing.T) {
input := `<p>This link is <a href="mailto:jsmith@example.com?subject=A%20Test&amp;body=My%20idea%20is%3A%20%0A">valid</a></p>`
expected := `<p>This link is <a href="mailto:jsmith@example.com?subject=A%20Test&amp;body=My%20idea%20is%3A%20%0A" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">valid</a></p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestTelURIScheme(t *testing.T) {
input := `<p>This link is <a href="tel:+1-201-555-0123">valid</a></p>`
expected := `<p>This link is <a href="tel:+1-201-555-0123" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">valid</a></p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestXMPPURIScheme(t *testing.T) {
input := `<p>This link is <a href="xmpp:user@host?subscribe&amp;type=subscribed">valid</a></p>`
expected := `<p>This link is <a href="xmpp:user@host?subscribe&amp;type=subscribed" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">valid</a></p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestBlacklistedLink(t *testing.T) {
input := `<p>This image is not valid <img src="https://stats.wordpress.com/some-tracker"></p>`
expected := `<p>This image is not valid </p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestXmlEntities(t *testing.T) {
input := `<pre>echo "test" &gt; /etc/hosts</pre>`
expected := `<pre>echo &#34;test&#34; &gt; /etc/hosts</pre>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestEspaceAttributes(t *testing.T) {
input := `<td rowspan="<b>test</b>">test</td>`
expected := `<td rowspan="&lt;b&gt;test&lt;/b&gt;">test</td>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestReplaceIframeURL(t *testing.T) {
input := `<iframe src="https://player.vimeo.com/video/123456?title=0&amp;byline=0"></iframe>`
expected := `<div class="video-wrapper"><iframe src="https://player.vimeo.com/video/123456?title=0&amp;byline=0" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestReplaceNoScript(t *testing.T) {
input := `<p>Before paragraph.</p><noscript>Inside <code>noscript</code> tag with an image: <img src="http://example.org/" alt="Test" loading="lazy"></noscript><p>After paragraph.</p>`
expected := `<p>Before paragraph.</p><p>After paragraph.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestReplaceScript(t *testing.T) {
input := `<p>Before paragraph.</p><script type="text/javascript">alert("1");</script><p>After paragraph.</p>`
expected := `<p>Before paragraph.</p><p>After paragraph.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestReplaceStyle(t *testing.T) {
input := `<p>Before paragraph.</p><style>body { background-color: #ff0000; }</style><p>After paragraph.</p>`
expected := `<p>Before paragraph.</p><p>After paragraph.</p>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
}
}
func TestWrapYoutubeIFrames(t *testing.T) {
input := `<iframe src="https://www.youtube.com/embed/foobar"></iframe>`
expected := `<div class="video-wrapper"><iframe src="https://www.youtube.com/embed/foobar" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
output := Sanitize("http://example.org/", input)
if expected != output {
t.Errorf("Wrong output:\nwant: %v\nhave: %v", expected, output)
}
}

View File

@@ -0,0 +1,422 @@
package sanitizer
type set struct {
m map[string]bool
}
func sset(vals []string) set {
m := make(map[string]bool)
for _, val := range vals {
m[val] = true
}
return set{m: m}
}
func (s *set) has(val string) bool {
_, ok := s.m[val]
return ok
}
// taken from: https://github.com/cure53/DOMPurify/blob/e1c19cf6/src/tags.js
var allowedTags = sset([]string{
"a",
"abbr",
"acronym",
"address",
"area",
"article",
"aside",
"audio",
"b",
"bdi",
"bdo",
"big",
"blink",
"blockquote",
"body",
"br",
"button",
"canvas",
"caption",
"center",
"cite",
"code",
"col",
"colgroup",
"content",
"data",
"datalist",
"dd",
"decorator",
"del",
"details",
"dfn",
"dialog",
"dir",
"div",
"dl",
"dt",
"element",
"em",
"fieldset",
"figcaption",
"figure",
"font",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hgroup",
"hr",
"html",
"i",
"iframe",
"img",
"input",
"ins",
"kbd",
"label",
"legend",
"li",
"main",
"map",
"mark",
"marquee",
"menu",
"menuitem",
"meter",
"nav",
"nobr",
"ol",
"optgroup",
"option",
"output",
"p",
"picture",
"pre",
"progress",
"q",
"rp",
"rt",
"ruby",
"s",
"samp",
"section",
"select",
"shadow",
"small",
"source",
"spacer",
"span",
"strike",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"template",
"textarea",
"tfoot",
"th",
"thead",
"time",
"tr",
"track",
"tt",
"u",
"ul",
"var",
"video",
"wbr",
})
var allowedSvgTags = sset([]string{
"svg",
"a",
"altglyph",
"altglyphdef",
"altglyphitem",
"animatecolor",
"animatemotion",
"animatetransform",
"circle",
"clippath",
"defs",
"desc",
"ellipse",
"filter",
"font",
"g",
"glyph",
"glyphref",
"hkern",
"image",
"line",
"lineargradient",
"marker",
"mask",
"metadata",
"mpath",
"path",
"pattern",
"polygon",
"polyline",
"radialgradient",
"rect",
"stop",
//"style",
"switch",
"symbol",
"text",
"textpath",
"title",
"tref",
"tspan",
"view",
"vkern",
})
var allowedSvgFilters = sset([]string{
"feBlend",
"feColorMatrix",
"feComponentTransfer",
"feComposite",
"feConvolveMatrix",
"feDiffuseLighting",
"feDisplacementMap",
"feDistantLight",
"feFlood",
"feFuncA",
"feFuncB",
"feFuncG",
"feFuncR",
"feGaussianBlur",
"feMerge",
"feMergeNode",
"feMorphology",
"feOffset",
"fePointLight",
"feSpecularLighting",
"feSpotLight",
"feTile",
"feTurbulence",
})
var allowedAttrs = map[string]set{
"img": sset([]string{"alt", "title", "src", "srcset", "sizes"}),
"audio": sset([]string{"src"}),
"video": sset([]string{"poster", "height", "width", "src"}),
"source": sset([]string{"src", "type", "srcset", "sizes", "media"}),
"td": sset([]string{"rowspan", "colspan"}),
"th": sset([]string{"rowspan", "colspan"}),
"q": sset([]string{"cite"}),
"a": sset([]string{"href", "title"}),
"time": sset([]string{"datetime"}),
"abbr": sset([]string{"title"}),
"acronym": sset([]string{"title"}),
"iframe": sset([]string{"width", "height", "frameborder", "src", "allowfullscreen"}),
}
var allowedSvgAttrs = sset([]string{
"accent-height",
"accumulate",
"additive",
"alignment-baseline",
"ascent",
"attributename",
"attributetype",
"azimuth",
"basefrequency",
"baseline-shift",
"begin",
"bias",
"by",
"class",
"clip",
"clippathunits",
"clip-path",
"clip-rule",
"color",
"color-interpolation",
"color-interpolation-filters",
"color-profile",
"color-rendering",
"cx",
"cy",
"d",
"dx",
"dy",
"diffuseconstant",
"direction",
"display",
"divisor",
"dur",
"edgemode",
"elevation",
"end",
"fill",
"fill-opacity",
"fill-rule",
"filter",
"filterunits",
"flood-color",
"flood-opacity",
"font-family",
"font-size",
"font-size-adjust",
"font-stretch",
"font-style",
"font-variant",
"font-weight",
"fx",
"fy",
"g1",
"g2",
"glyph-name",
"glyphref",
"gradientunits",
"gradienttransform",
"height",
"href",
"id",
"image-rendering",
"in",
"in2",
"k",
"k1",
"k2",
"k3",
"k4",
"kerning",
"keypoints",
"keysplines",
"keytimes",
"lang",
"lengthadjust",
"letter-spacing",
"kernelmatrix",
"kernelunitlength",
"lighting-color",
"local",
"marker-end",
"marker-mid",
"marker-start",
"markerheight",
"markerunits",
"markerwidth",
"maskcontentunits",
"maskunits",
"max",
"mask",
"media",
"method",
"mode",
"min",
"name",
"numoctaves",
"offset",
"operator",
"opacity",
"order",
"orient",
"orientation",
"origin",
"overflow",
"paint-order",
"path",
"pathlength",
"patterncontentunits",
"patterntransform",
"patternunits",
"points",
"preservealpha",
"preserveaspectratio",
"primitiveunits",
"r",
"rx",
"ry",
"radius",
"refx",
"refy",
"repeatcount",
"repeatdur",
"restart",
"result",
"rotate",
"scale",
"seed",
"shape-rendering",
"specularconstant",
"specularexponent",
"spreadmethod",
"startoffset",
"stddeviation",
"stitchtiles",
"stop-color",
"stop-opacity",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-opacity",
"stroke",
"stroke-width",
//"style",
"surfacescale",
"systemlanguage",
"tabindex",
"targetx",
"targety",
"transform",
"text-anchor",
"text-decoration",
"text-rendering",
"textlength",
"type",
"u1",
"u2",
"unicode",
"values",
"viewbox",
"visibility",
"version",
"vert-adv-y",
"vert-origin-x",
"vert-origin-y",
"width",
"word-spacing",
"wrap",
"writing-mode",
"xchannelselector",
"ychannelselector",
"x",
"x1",
"x2",
"xmlns",
"y",
"y1",
"y2",
"z",
"zoomandpan",
})
var allowedURISchemes = sset([]string{
"http",
"https",
"ftp",
"ftps",
"tel",
"mailto",
"callto",
"cid",
"xmpp",
})

View File

@@ -0,0 +1,110 @@
package scraper
import (
"net/url"
"strings"
"github.com/nkanaev/yarr/src/content/htmlutil"
"golang.org/x/net/html"
)
func FindFeeds(body string, base string) map[string]string {
candidates := make(map[string]string)
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
return candidates
}
// find direct links
// css: link[type=application/atom+xml]
linkTypes := []string{"application/atom+xml", "application/rss+xml", "application/json"}
isFeedLink := func(n *html.Node) bool {
if n.Type == html.ElementNode && n.Data == "link" {
t := htmlutil.Attr(n, "type")
for _, tt := range linkTypes {
if tt == t {
return true
}
}
}
return false
}
for _, node := range htmlutil.FindNodes(doc, isFeedLink) {
href := htmlutil.Attr(node, "href")
name := htmlutil.Attr(node, "title")
link := htmlutil.AbsoluteUrl(href, base)
if link != "" {
candidates[link] = name
l, err := url.Parse(link)
if err == nil && l.Host == "www.youtube.com" && l.Path == "/feeds/videos.xml" {
// https://wiki.archiveteam.org/index.php/YouTube/Technical_details#Playlists
channelID, found := strings.CutPrefix(l.Query().Get("channel_id"), "UC")
if found {
const url string = "https://www.youtube.com/feeds/videos.xml?playlist_id="
candidates[url+"UULF"+channelID] = name + " - Videos"
candidates[url+"UULV"+channelID] = name + " - Live Streams"
candidates[url+"UUSH"+channelID] = name + " - Short videos"
}
}
}
}
// guess by hyperlink properties
if len(candidates) == 0 {
// css: a[href="feed"]
// css: a:contains("rss")
feedHrefs := []string{"feed", "feed.xml", "rss.xml", "atom.xml"}
feedTexts := []string{"rss", "feed"}
isFeedHyperLink := func(n *html.Node) bool {
if n.Type == html.ElementNode && n.Data == "a" {
href := strings.Trim(htmlutil.Attr(n, "href"), "/")
for _, feedHref := range feedHrefs {
if strings.HasSuffix(href, feedHref) {
return true
}
}
text := htmlutil.Text(n)
for _, feedText := range feedTexts {
if strings.EqualFold(text, feedText) {
return true
}
}
}
return false
}
for _, node := range htmlutil.FindNodes(doc, isFeedHyperLink) {
href := htmlutil.Attr(node, "href")
link := htmlutil.AbsoluteUrl(href, base)
if link != "" {
candidates[link] = ""
}
}
}
return candidates
}
func FindIcons(body string, base string) []string {
icons := make([]string, 0)
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
return icons
}
// css: link[rel=icon]
isLink := func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "link"
}
for _, node := range htmlutil.FindNodes(doc, isLink) {
rels := strings.Split(htmlutil.Attr(node, "rel"), " ")
for _, rel := range rels {
if strings.EqualFold(rel, "icon") {
icons = append(icons, htmlutil.AbsoluteUrl(htmlutil.Attr(node, "href"), base))
}
}
}
return icons
}

View File

@@ -0,0 +1,97 @@
package scraper
import (
"reflect"
"testing"
)
const base = "http://example.com"
func TestFindFeedsInvalidHTML(t *testing.T) {
x := `some nonsense`
r := FindFeeds(x, base)
if len(r) != 0 {
t.Fatal("not expecting results")
}
}
func TestFindFeedsLinks(t *testing.T) {
x := `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
<link rel="alternate" href="/feed.xml" type="application/rss+xml" title="rss with title">
<link rel="alternate" href="/atom.xml" type="application/atom+xml">
<link rel="alternate" href="/feed.json" type="application/json">
</head>
<body>
<a href="/feed.xml">rss</a>
</body>
</html>
`
have := FindFeeds(x, base)
want := map[string]string{
base + "/feed.xml": "rss with title",
base + "/atom.xml": "",
base + "/feed.json": "",
}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid result")
}
}
func TestFindFeedsGuess(t *testing.T) {
body := `
<!DOCTYPE html>
<html lang="en">
<body>
<!-- negative -->
<a href="/about">what is rss?</a>
<a href="/feed/cows">moo</a>
<!-- positive -->
<a href="/feed.xml">subscribe</a>
<a href="/news">rss</a>
</body>
</html>
`
have := FindFeeds(body, base)
want := map[string]string{
base + "/feed.xml": "",
base + "/news": "",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid result")
}
}
func TestFindIcons(t *testing.T) {
body := `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
<link rel="icon favicon" href="/favicon.ico">
<link rel="icon macicon" href="path/to/favicon.png">
</head>
<body>
</body>
</html>
`
have := FindIcons(body, base)
want := []string{base + "/favicon.ico", base + "/path/to/favicon.png"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid result")
}
}

View File

@@ -0,0 +1,38 @@
package silo
import (
"fmt"
"net/url"
"regexp"
"strings"
)
var (
youtubeFrame = `<iframe src="https://www.youtube.com/embed/%s" width="560" height="315" frameborder="0" allowfullscreen></iframe>`
vimeoFrame = `<iframe src="https://player.vimeo.com/video/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`
vimeoRegex = regexp.MustCompile(`\/(\d+)$`)
)
func VideoIFrame(link string) string {
l, err := url.Parse(link)
if err != nil {
return ""
}
youtubeID := ""
if l.Host == "www.youtube.com" && l.Path == "/watch" {
youtubeID = l.Query().Get("v")
} else if l.Host == "youtu.be" {
youtubeID = strings.TrimLeft(l.Path, "/")
}
if youtubeID != "" {
return fmt.Sprintf(youtubeFrame, youtubeID)
}
if l.Host == "vimeo.com" {
if matches := vimeoRegex.FindStringSubmatch(l.Path); len(matches) > 0 {
return fmt.Sprintf(vimeoFrame, matches[1])
}
}
return ""
}

View File

@@ -0,0 +1,36 @@
package silo
import "testing"
func TestYoutubeIframe(t *testing.T) {
links := []string{
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
}
for _, link := range links {
have := VideoIFrame(link)
want := `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" frameborder="0" allowfullscreen></iframe>`
if have != want {
t.Logf("want: %s", want)
t.Logf("have: %s", have)
t.Fail()
}
}
}
func TestVimeoIframe(t *testing.T) {
links := []string{
"https://vimeo.com/channels/staffpicks/526381128",
"https://vimeo.com/526381128",
}
for _, link := range links {
have := VideoIFrame(link)
want := `<iframe src="https://player.vimeo.com/video/526381128" width="640" height="360" frameborder="0" allowfullscreen></iframe>`
if have != want {
t.Logf("want: %s", want)
t.Logf("have: %s", have)
t.Fail()
}
}
}

17
src/content/silo/url.go Normal file
View File

@@ -0,0 +1,17 @@
package silo
import (
"net/url"
"strings"
)
func RedirectURL(link string) string {
if strings.HasPrefix(link, "https://www.google.com/url?") {
if u, err := url.Parse(link); err == nil {
if u2 := u.Query().Get("url"); u2 != "" {
return u2
}
}
}
return link
}

View File

@@ -0,0 +1,24 @@
package silo
import "testing"
func TestRedirectURL(t *testing.T) {
link := "https://www.google.com/url?rct=j&sa=t&url=https://www.cryptoglobe.com/latest/2022/08/investment-strategist-lyn-alden-explains-why-she-is-still-bullish-on-bitcoin-long-term/&ct=ga&cd=CAIyGjlkMjI1NjUyODE3ODFjMDQ6Y29tOmVuOlVT&usg=AOvVaw16C2fJtw6m8QVEbto2HCKK"
want := "https://www.cryptoglobe.com/latest/2022/08/investment-strategist-lyn-alden-explains-why-she-is-still-bullish-on-bitcoin-long-term/"
have := RedirectURL(link)
if have != want {
t.Logf("want: %s", want)
t.Logf("have: %s", have)
t.Fail()
}
link = "https://example.com"
if RedirectURL(link) != link {
t.Fail()
}
link = "https://example.com/url?url=test.com"
if RedirectURL(link) != link {
t.Fail()
}
}

Submodule src/gofeed deleted from 70d9d43ec2

View File

@@ -1,109 +0,0 @@
package main
import (
"bufio"
"flag"
"fmt"
"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"
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 {
platform.Open(srv.GetAddr())
}
platform.Start(srv)
}

105
src/parser/atom.go Normal file
View File

@@ -0,0 +1,105 @@
// Atom 1.0 parser
package parser
import (
"encoding/xml"
"io"
"strings"
"github.com/nkanaev/yarr/src/content/htmlutil"
)
type atomFeed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
ID string `xml:"id"`
Title atomText `xml:"title"`
Links atomLinks `xml:"link"`
Entries []atomEntry `xml:"entry"`
}
type atomEntry struct {
ID string `xml:"id"`
Title atomText `xml:"title"`
Summary atomText `xml:"summary"`
Published string `xml:"published"`
Updated string `xml:"updated"`
Links atomLinks `xml:"link"`
Content atomText `xml:"http://www.w3.org/2005/Atom content"`
OrigLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
media
}
type atomText struct {
Type string `xml:"type,attr"`
Data string `xml:",chardata"`
XML string `xml:",innerxml"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr"`
}
type atomLinks []atomLink
func (a *atomText) Text() string {
if a.Type == "html" {
return htmlutil.ExtractText(a.Data)
} else if a.Type == "xhtml" {
return htmlutil.ExtractText(a.XML)
}
return a.Data
}
func (a *atomText) String() string {
data := a.Data
if a.Type == "xhtml" {
data = a.XML
}
return strings.TrimSpace(data)
}
func (links atomLinks) First(rel string) string {
for _, l := range links {
if l.Rel == rel {
return l.Href
}
}
return ""
}
func ParseAtom(r io.Reader) (*Feed, error) {
srcfeed := atomFeed{}
decoder := xmlDecoder(r)
if err := decoder.Decode(&srcfeed); err != nil {
return nil, err
}
dstfeed := &Feed{
Title: srcfeed.Title.String(),
SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")),
}
for _, srcitem := range srcfeed.Entries {
linkFromID := ""
guidFromID := ""
if htmlutil.IsAPossibleLink(srcitem.ID) {
linkFromID = srcitem.ID
guidFromID = srcitem.ID + "::" + srcitem.Updated
}
mediaLinks := srcitem.mediaLinks()
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""), linkFromID)
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link,
Title: srcitem.Title.Text(),
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
MediaLinks: mediaLinks,
})
}
return dstfeed, nil
}

236
src/parser/atom_test.go Normal file
View File

@@ -0,0 +1,236 @@
package parser
import (
"reflect"
"strings"
"testing"
"time"
)
func TestAtom(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<subtitle>A subtitle.</subtitle>
<link href="http://example.org/feed/" rel="self" />
<link href="http://example.org/" />
<id>urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6</id>
<updated>2003-12-13T18:30:02Z</updated>
<entry>
<title>Atom-Powered Robots Run Amok</title>
<link href="http://example.org/2003/12/13/atom03" />
<link rel="alternate" type="text/html" href="http://example.org/2003/12/13/atom03.html"/>
<link rel="edit" href="http://example.org/2003/12/13/atom03/edit"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
<content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>
</content>
<author>
<name>John Doe</name>
<email>johndoe@example.com</email>
</author>
</entry>
</feed>
`))
want := &Feed{
Title: "Example Feed",
SiteURL: "http://example.org/",
Items: []Item{
{
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
Date: time.Unix(1071340202, 0).UTC(),
URL: "http://example.org/2003/12/13/atom03.html",
Title: "Atom-Powered Robots Run Amok",
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid atom")
}
}
func TestAtomClashingNamespaces(t *testing.T) {
have, err := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<content>atom content</content>
<media:content xmlns:media="http://search.yahoo.com/mrss/" />
</entry>
</feed>
`))
want := &Feed{Items: []Item{{Content: "atom content"}}}
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestAtomHTMLTitle(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title type="html">say &lt;code&gt;what&lt;/code&gt;?</entry>
</feed>
`))
have := feed.Items[0].Title
want := "say what?"
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestAtomXHTMLTitle(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title type="xhtml">say &lt;code&gt;what&lt;/code&gt;?</entry>
</feed>
`))
have := feed.Items[0].Title
want := "say what?"
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestAtomXHTMLNestedTitle(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml">
<a href="https://example.com">Link to Example</a>
</div>
</title>
</entry>
</feed>
`))
have := feed.Items[0].Title
want := "Link to Example"
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestAtomImageLink(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<entry>
<media:thumbnail url="https://example.com/image.png?width=100&height=100" />
</entry>
</feed>
`))
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatalf("Expected 1 media link, got: %#v", feed.Items[0].MediaLinks)
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: `https://example.com/image.png?width=100&height=100`,
Type: "image",
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("item.image_url doesn't match\nwant: %#v\nhave: %#v\n", want, have)
}
}
// found in: https://www.reddit.com/r/funny.rss
// items come with thumbnail urls which are also present in the content
func TestAtomImageLinkDuplicated(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<entry>
<content type="html">&lt;img src="https://example.com/image.png?width=100&amp;height=100"&gt;</content>
<media:thumbnail url="https://example.com/image.png?width=100&height=100" />
</entry>
</feed>
`))
have := feed.Items[0].Content
want := `<img src="https://example.com/image.png?width=100&height=100">`
if want != have {
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
}
if len(feed.Items[0].MediaLinks) != 0 {
t.Fatal("item media link must be excluded if present in the content")
}
}
func TestAtomLinkInID(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<entry>
<title>one updated</title>
<id>https://example.com/posts/1</id>
<updated>2003-12-13T09:17:51</updated>
</entry>
<entry>
<title>two</title>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
</entry>
<entry>
<title>one</title>
<id>https://example.com/posts/1</id>
</entry>
</feed>
`))
have := feed.Items
want := []Item{
Item{
GUID: "https://example.com/posts/1::2003-12-13T09:17:51",
Date: time.Date(2003, time.December, 13, 9, 17, 51, 0, time.UTC),
URL: "https://example.com/posts/1",
Title: "one updated",
},
Item{
GUID: "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6",
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), URL: "",
Title: "two",
},
Item{
GUID: "https://example.com/posts/1::",
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
URL: "https://example.com/posts/1",
Title: "one",
Content: "",
},
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("\nwant: %#v\nhave: %#v\n", want, have)
}
}
func TestAtomDoesntEscapeHTMLTags(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><summary type="html">&amp;lt;script&amp;gt;alert(1);&amp;lt;/script&amp;gt;</summary></entry>
</feed>
`))
have := feed.Items[0].Content
want := "&lt;script&gt;alert(1);&lt;/script&gt;"
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}

218
src/parser/date.go Normal file
View File

@@ -0,0 +1,218 @@
package parser
import "time"
// taken from github.com/mjibson/goread
var dateFormats = []string{
time.RFC822, // RSS
time.RFC822Z, // RSS
time.RFC3339, // Atom
time.UnixDate,
time.RubyDate,
time.RFC850,
time.RFC1123Z,
time.RFC1123,
time.ANSIC,
"Mon, 02 Jan 2006 15:04:05 MST -07:00",
"Mon, January 2, 2006, 3:04 PM MST",
"Mon, January 2 2006 15:04:05 -0700",
"Mon, January 02, 2006, 15:04:05 MST",
"Mon, January 02, 2006 15:04:05 MST",
"Mon, Jan 2, 2006 15:04 MST",
"Mon, Jan 2 2006 15:04 MST",
"Mon, Jan 2 2006 15:04:05 MST",
"Mon, Jan 2, 2006 15:04:05 MST",
"Mon, Jan 2 2006 15:04:05 -700",
"Mon, Jan 2 2006 15:04:05 -0700",
"Mon Jan 2 15:04 2006",
"Mon Jan 2 15:04:05 2006 MST",
"Mon Jan 02, 2006 3:04 pm",
"Mon, Jan 02,2006 15:04:05 MST",
"Mon Jan 02 2006 15:04:05 -0700",
"Mon, 02/01/2006",
"Monday, 2. January 2006 - 15:04",
"Monday 02 January 2006",
"Monday, January 2, 2006 15:04:05 MST",
"Monday, January 2, 2006 03:04 PM",
"Monday, January 2, 2006",
"Monday, January 02, 2006",
"Monday, 2 January 2006 15:04:05 MST",
"Monday, 2 January 2006 15:04:05 -0700",
"Monday, 2 Jan 2006 15:04:05 MST",
"Monday, 2 Jan 2006 15:04:05 -0700",
"Monday, 02 January 2006 15:04:05 MST",
"Monday, 02 January 2006 15:04:05 -0700",
"Monday, 02 January 2006 15:04:05",
"Monday, January 02, 2006 - 3:04pm",
"Monday, January 2, 2006 - 3:04pm",
"Mon, 01/02/2006 - 15:04",
"Mon, 2 January 2006 15:04 MST",
"Mon, 2 January 2006, 15:04 -0700",
"Mon, 2 January 2006, 15:04:05 MST",
"Mon, 2 January 2006 15:04:05 MST",
"Mon, 2 January 2006 15:04:05 -0700",
"Mon, 2 January 2006",
"nilMon, 2 Jan 2006 3:04:05 PM -0700",
"Mon, 2 Jan 2006 15:4:5 MST",
"Mon, 2 Jan 2006 15:4:5 -0700 GMT",
"Mon, 2, Jan 2006 15:4",
"Mon, 2 Jan 2006 15:04 MST",
"Mon, 2 Jan 2006, 15:04 -0700",
"Mon, 2 Jan 2006 15:04 -0700",
"Mon, 2 Jan 2006 15:04:05 UT",
"Mon, 2 Jan 2006 15:04:05MST",
"Mon, 2 Jan 2006 15:04:05 MST",
"Mon 2 Jan 2006 15:04:05 MST",
"mon,2 Jan 2006 15:04:05 MST",
"Mon, 2 Jan 2006 15:04:05 -0700 MST",
"Mon, 2 Jan 2006 15:04:05-0700",
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05",
"Mon, 2 Jan 2006 15:04",
"Mon, 02 Jan 2006, 15:04",
"Mon, 2 Jan 2006, 15:04",
"Mon,2 Jan 2006",
"Mon, 2 Jan 2006",
"Mon, 2 Jan 15:04:05 MST",
"Mon, 2 Jan 06 15:04:05 MST",
"Mon, 2 Jan 06 15:04:05 -0700",
"Mon, 2006-01-02 15:04",
"Mon,02 January 2006 14:04:05 MST",
"Mon, 02 January 2006",
"Mon, 02 Jan 2006 3:04:05 PM MST",
"Mon, 02 Jan 2006 15 -0700",
"Mon,02 Jan 2006 15:04 MST",
"Mon, 02 Jan 2006 15:04 MST",
"Mon, 02 Jan 2006 15:04 -0700",
"Mon, 02 Jan 2006 15:04:05 Z",
"Mon, 02 Jan 2006 15:04:05 UT",
"Mon, 02 Jan 2006 15:04:05 MST-07:00",
"Mon, 02 Jan 2006 15:04:05 MST -0700",
"Mon, 02 Jan 2006, 15:04:05 MST",
"Mon, 02 Jan 2006 15:04:05MST",
"Mon, 02 Jan 2006 15:04:05 MST",
"Mon , 02 Jan 2006 15:04:05 MST",
"Mon, 02 Jan 2006 15:04:05 GMT-0700",
"Mon,02 Jan 2006 15:04:05 -0700",
"Mon, 02 Jan 2006 15:04:05 -0700",
"Mon, 02 Jan 2006 15:04:05 -07:00",
"Mon, 02 Jan 2006 15:04:05 --0700",
"Mon 02 Jan 2006 15:04:05 -0700",
"Mon 02 Jan 2006, 15:04:05 MST",
"Mon, 02 Jan 2006 15:04:05 MST",
"Mon, 02 Jan 2006 15:04:05 -07",
"Mon, 02 Jan 2006 15:04:05 00",
"Mon, 02 Jan 2006 15:04:05",
"Mon, 02 Jan 2006",
"Mon, 02 Jan 06 15:04:05 MST",
"Mon, 02 Jan 2006 3:04 PM MST",
"Mon Jan 02 2006 15:04:05 MST",
"Mon, 01 02 2006 15:04:05 -0700",
"Mon, 2th Jan 2006 15:05:05 MST",
"Jan. 2, 2006, 3:04 a.m.",
"fri, 02 jan 2006 15:04:05 -0700",
"January 02 2006 03:04:05 PM",
"January 2, 2006 3:04 PM",
"January 2, 2006, 3:04 p.m.",
"January 2, 2006 15:04:05 MST",
"January 2, 2006 15:04:05",
"January 2, 2006 03:04 PM",
"January 2, 2006",
"January 02, 2006 15:04:05 MST",
"January 02, 2006 15:04",
"January 02, 2006 03:04 PM",
"January 02, 2006",
"Jan 2, 2006 3:04:05 PM MST",
"Jan 2, 2006 3:04:05 PM",
"Jan 2, 2006 15:04:05 MST",
"Jan 2, 2006",
"Jan 02 2006 03:04:05PM",
"Jan 02, 2006",
"6/1/2 15:04",
"6-1-2 15:04",
"2 January 2006 15:04:05 MST",
"2 January 2006 15:04:05 -0700",
"2 January 2006",
"2 Jan 2006 15:04:05 Z",
"2 Jan 2006 15:04:05 MST",
"2 Jan 2006 15:04:05 -0700",
"2 Jan 2006",
"2 Jan 2006 15:04 MST",
"2.1.2006 15:04:05",
"2/1/2006",
"2-1-2006",
"2006 January 02",
"2006-1-2T15:04:05Z",
"2006-1-2 15:04:05",
"2006-1-2",
"2006-01-02T15:04:05-07:00Z",
"2006-1-02T15:04:05Z",
"2006-01-02T15:04Z",
"2006-01-02T15:04-07:00",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05-07:00:00",
"2006-01-02T15:04:05:-0700",
"2006-01-02T15:04:05-0700",
"2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05 -0700",
"2006-01-02T15:04:05:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 at 15:04:05",
"2006-01-02 15:04:05Z",
"2006-01-02 15:04:05 MST",
"2006-01-02 15:04:05-0700",
"2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05 -0700",
"2006-01-02 15:04",
"2006-01-02 00:00:00.0 15:04:05.0 -0700",
"2006/01/02",
"2006-01-02",
"15:04 02.01.2006 -0700",
"1/2/2006 3:04 PM MST",
"1/2/2006 3:04:05 PM MST",
"1/2/2006 3:04:05 PM",
"1/2/2006 15:04:05 MST",
"1/2/2006",
"06/1/2 15:04",
"06-1-2 15:04",
"02 Monday, Jan 2006 15:04",
"02 Jan 2006 15:04 MST",
"02 Jan 2006 15:04:05 UT",
"02 Jan 2006 15:04:05 MST",
"02 Jan 2006 15:04:05 -0700",
"02 Jan 2006 15:04:05",
"02 Jan 2006",
"02/01/2006 15:04 MST",
"02-01-2006 15:04:05 MST",
"02.01.2006 15:04:05",
"02/01/2006 15:04:05",
"02.01.2006 15:04",
"02/01/2006 - 15:04",
"02.01.2006 -0700",
"02/01/2006",
"02-01-2006",
"01/02/2006 3:04 PM",
"01/02/2006 15:04:05 MST",
"01/02/2006 - 15:04",
"01/02/2006",
"01-02-2006",
"Jan. 2006",
"Jan. 2, 2006, 03:04 p.m.",
"2006-01-02 15:04:05 -07:00",
"2 January, 2006",
}
var defaultTime = time.Time{}
func dateParse(line string) time.Time {
if line == "" {
return defaultTime
}
for _, layout := range dateFormats {
if t, err := time.Parse(layout, line); err == nil {
return t
}
}
return defaultTime
}

184
src/parser/feed.go Normal file
View File

@@ -0,0 +1,184 @@
package parser
import (
"bytes"
"crypto/sha256"
"encoding/xml"
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/nkanaev/yarr/src/content/htmlutil"
"golang.org/x/net/html/charset"
)
var UnknownFormat = errors.New("unknown feed format")
type feedProbe struct {
feedType string
callback func(r io.Reader) (*Feed, error)
encoding string
}
func sniff(lookup string) (out feedProbe) {
lookup = strings.TrimSpace(lookup)
lookup = strings.TrimLeft(lookup, "\x00\xEF\xBB\xBF\xFE\xFF")
if len(lookup) == 0 {
return
}
switch lookup[0] {
case '<':
decoder := xmlDecoder(strings.NewReader(lookup))
for {
token, _ := decoder.Token()
if token == nil {
break
}
// check <?xml encoding="ENCODING" ?>
if el, ok := token.(xml.ProcInst); ok && el.Target == "xml" {
out.encoding = strings.ToLower(procInst("encoding", string(el.Inst)))
}
if el, ok := token.(xml.StartElement); ok {
switch el.Name.Local {
case "rss":
out.feedType = "rss"
out.callback = ParseRSS
return
case "RDF":
out.feedType = "rdf"
out.callback = ParseRDF
return
case "feed":
out.feedType = "atom"
out.callback = ParseAtom
return
}
}
}
case '{':
out.feedType = "json"
out.callback = ParseJSON
return
}
return
}
func Parse(r io.Reader) (*Feed, error) {
return ParseWithEncoding(r, "")
}
func ParseWithEncoding(r io.Reader, fallbackEncoding string) (*Feed, error) {
lookup := make([]byte, 2048)
n, err := io.ReadFull(r, lookup)
switch {
case err == io.ErrUnexpectedEOF:
lookup = lookup[:n]
r = bytes.NewReader(lookup)
case err != nil:
return nil, err
default:
r = io.MultiReader(bytes.NewReader(lookup), r)
}
out := sniff(string(lookup))
if out.feedType == "" {
return nil, UnknownFormat
}
if out.encoding == "" && fallbackEncoding != "" {
r, err = charset.NewReaderLabel(fallbackEncoding, r)
if err != nil {
return nil, err
}
}
if (out.feedType != "json") && (out.encoding == "" || out.encoding == "utf-8") {
// XML decoder will not rely on custom CharsetReader (see `xmlDecoder`)
// to handle invalid xml characters.
// Assume input is already UTF-8 and do the cleanup here.
r = NewSafeXMLReader(r)
}
feed, err := out.callback(r)
if feed != nil {
feed.cleanup()
}
return feed, err
}
func ParseAndFix(r io.Reader, baseURL, fallbackEncoding string) (*Feed, error) {
feed, err := ParseWithEncoding(r, fallbackEncoding)
if err != nil {
return nil, err
}
feed.TranslateURLs(baseURL)
feed.SetMissingDatesTo(time.Now())
feed.SetMissingGUIDs()
return feed, nil
}
func (feed *Feed) cleanup() {
feed.Title = strings.TrimSpace(feed.Title)
feed.SiteURL = strings.TrimSpace(feed.SiteURL)
for i, item := range feed.Items {
feed.Items[i].GUID = strings.TrimSpace(item.GUID)
feed.Items[i].URL = strings.TrimSpace(item.URL)
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
feed.Items[i].Content = strings.TrimSpace(item.Content)
if len(feed.Items[i].MediaLinks) > 0 {
mediaLinks := make([]MediaLink, 0)
for _, link := range item.MediaLinks {
if !strings.Contains(item.Content, link.URL) {
mediaLinks = append(mediaLinks, link)
}
}
feed.Items[i].MediaLinks = mediaLinks
}
}
}
func (feed *Feed) SetMissingDatesTo(newdate time.Time) {
for i, item := range feed.Items {
if item.Date.IsZero() {
feed.Items[i].Date = newdate
}
}
}
func (feed *Feed) TranslateURLs(base string) error {
baseUrl, err := url.Parse(base)
if err != nil {
return fmt.Errorf("failed to parse base url: %#v", base)
}
siteUrl, err := url.Parse(feed.SiteURL)
if err != nil {
return fmt.Errorf("failed to parse feed url: %#v", feed.SiteURL)
}
feed.SiteURL = baseUrl.ResolveReference(siteUrl).String()
for _, item := range feed.Items {
itemUrl, err := url.Parse(item.URL)
if err != nil {
return fmt.Errorf("failed to parse item url: %#v", item.URL)
}
item.URL = siteUrl.ResolveReference(itemUrl).String()
}
return nil
}
func (feed *Feed) SetMissingGUIDs() {
for i, item := range feed.Items {
if item.GUID == "" {
id := strings.Join([]string{item.Title, item.Date.Format(time.RFC3339), item.URL}, ";;")
feed.Items[i].GUID = fmt.Sprintf("%x", sha256.Sum256([]byte(id)))
}
}
}

181
src/parser/feed_test.go Normal file
View File

@@ -0,0 +1,181 @@
package parser
import (
"reflect"
"strings"
"testing"
)
func TestSniff(t *testing.T) {
testcases := []struct {
input string
want feedProbe
}{
{
`<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF>`,
feedProbe{feedType: "rdf", callback: ParseRDF},
},
{
`<?xml version="1.0" encoding="ISO-8859-1"?><rss version="2.0"><channel></channel></rss>`,
feedProbe{feedType: "rss", callback: ParseRSS, encoding: "iso-8859-1"},
},
{
`<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`,
feedProbe{feedType: "rss", callback: ParseRSS},
},
{
`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
feedProbe{feedType: "atom", callback: ParseAtom, encoding: "utf-8"},
},
{
`{}`,
feedProbe{feedType: "json", callback: ParseJSON},
},
{
`<!DOCTYPE html><html><head><title></title></head><body></body></html>`,
feedProbe{},
},
}
for _, testcase := range testcases {
want := testcase.want
have := sniff(testcase.input)
if want.encoding != have.encoding || want.feedType != have.feedType {
t.Errorf("Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v", testcase.input, want, have)
}
}
}
func TestParse(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>
Title
</title>
<item>
<title>
Item 1
</title>
<description>
<![CDATA[<div>content</div>]]>
</description>
</item>
</channel>
</rss>
`))
want := &Feed{
Title: "Title",
Items: []Item{
{
Title: "Item 1",
Content: "<div>content</div>",
},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid content")
}
}
func TestParseShortFeed(t *testing.T) {
have, err := Parse(strings.NewReader(
`<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
))
want := &Feed{}
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestParseFeedWithBOM(t *testing.T) {
have, err := Parse(strings.NewReader(
"\xEF\xBB\xBF" + `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
))
want := &Feed{}
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestParseCleanIllegalCharsInUTF8(t *testing.T) {
data := `
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<title>` + "\a" + `title</title>
</item>
</channel>
</rss>
`
feed, err := Parse(strings.NewReader(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Items) != 1 || feed.Items[0].Title != "title" {
t.Fatalf("invalid feed, got: %v", feed)
}
}
func TestParseCleanIllegalCharsInNonUTF8(t *testing.T) {
// echo привет | iconv -f utf8 -t cp1251 | hexdump -C
data := `
<?xml version="1.0" encoding="windows-1251"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<title>` + "\a \xef\xf0\xe8\xe2\xe5\xf2\x0a \a" + `</title>
</item>
</channel>
</rss>
`
feed, err := Parse(strings.NewReader(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Items) != 1 || feed.Items[0].Title != "привет" {
t.Fatalf("invalid feed, got: %v", feed)
}
}
func TestParseMissingGUID(t *testing.T) {
data := `
<?xml version="1.0" encoding="windows-1251"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<title>foo</title>
</item>
<item>
<title>bar</title>
</item>
</channel>
</rss>
`
feed, err := ParseAndFix(strings.NewReader(data), "", "")
if err != nil {
t.Fatal(err)
}
if len(feed.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(feed.Items))
}
if feed.Items[0].GUID == "" || feed.Items[1].GUID == "" {
t.Fatalf("item GUIDs are missing, got %#v", feed.Items)
}
if feed.Items[0].GUID == feed.Items[1].GUID {
t.Fatalf("item GUIDs are not unique, got %#v", feed.Items)
}
}

57
src/parser/json.go Normal file
View File

@@ -0,0 +1,57 @@
// JSON 1.0 parser
package parser
import (
"encoding/json"
"io"
)
type jsonFeed struct {
Version string `json:"version"`
Title string `json:"title"`
SiteURL string `json:"home_page_url"`
Items []jsonItem `json:"items"`
}
type jsonItem struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Summary string `json:"summary"`
Text string `json:"content_text"`
HTML string `json:"content_html"`
DatePublished string `json:"date_published"`
DateModified string `json:"date_modified"`
Attachments []jsonAttachment `json:"attachments"`
}
type jsonAttachment struct {
URL string `json:"url"`
MimeType string `json:"mime_type"`
Title string `json:"title"`
Size int64 `json:"size_in_bytes"`
Duration int `json:"duration_in_seconds"`
}
func ParseJSON(data io.Reader) (*Feed, error) {
srcfeed := new(jsonFeed)
decoder := json.NewDecoder(data)
if err := decoder.Decode(&srcfeed); err != nil {
return nil, err
}
dstfeed := &Feed{
Title: srcfeed.Title,
SiteURL: srcfeed.SiteURL,
}
for _, srcitem := range srcfeed.Items {
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.ID, srcitem.URL),
Date: dateParse(firstNonEmpty(srcitem.DatePublished, srcitem.DateModified)),
URL: srcitem.URL,
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.HTML, srcitem.Text, srcitem.Summary),
})
}
return dstfeed, nil
}

42
src/parser/json_test.go Normal file
View File

@@ -0,0 +1,42 @@
package parser
import (
"reflect"
"strings"
"testing"
)
func TestJSONFeed(t *testing.T) {
have, _ := Parse(strings.NewReader(`{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"id": "2",
"content_text": "This is a second item.",
"url": "https://example.org/second-item"
},
{
"id": "1",
"content_html": "<p>Hello, world!</p>",
"url": "https://example.org/initial-post"
}
]
}`))
want := &Feed{
Title: "My Example Feed",
SiteURL: "https://example.org/",
Items: []Item{
{GUID: "2", Content: "This is a second item.", URL: "https://example.org/second-item"},
{GUID: "1", Content: "<p>Hello, world!</p>", URL: "https://example.org/initial-post"},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid json")
}
}

111
src/parser/media.go Normal file
View File

@@ -0,0 +1,111 @@
package parser
import (
"strings"
)
type media struct {
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
}
type mediaGroup struct {
MediaContent []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
}
type mediaContent struct {
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaType string `xml:"type,attr"`
MediaMedium string `xml:"medium,attr"`
MediaURL string `xml:"url,attr"`
MediaDescription mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
}
type mediaThumbnail struct {
URL string `xml:"url,attr"`
}
type mediaDescription struct {
Type string `xml:"type,attr"`
Text string `xml:",chardata"`
}
func (m *media) firstMediaThumbnail() string {
for _, c := range m.MediaContents {
for _, t := range c.MediaThumbnails {
return t.URL
}
}
for _, t := range m.MediaThumbnails {
return t.URL
}
for _, g := range m.MediaGroups {
for _, t := range g.MediaThumbnails {
return t.URL
}
}
return ""
}
func (m *media) firstMediaDescription() string {
for _, d := range m.MediaDescriptions {
return plain2html(d.Text)
}
for _, g := range m.MediaGroups {
for _, d := range g.MediaDescriptions {
return plain2html(d.Text)
}
}
return ""
}
func (m *media) mediaLinks() []MediaLink {
links := make([]MediaLink, 0)
for _, thumbnail := range m.MediaThumbnails {
links = append(links, MediaLink{URL: thumbnail.URL, Type: "image"})
}
for _, group := range m.MediaGroups {
for _, thumbnail := range group.MediaThumbnails {
links = append(links, MediaLink{
URL: thumbnail.URL,
Type: "image",
})
}
}
for _, content := range m.MediaContents {
if content.MediaURL != "" {
url := content.MediaURL
description := content.MediaDescription.Text
if strings.HasPrefix(content.MediaType, "image/") {
links = append(links, MediaLink{URL: url, Type: "image", Description: description})
} else if strings.HasPrefix(content.MediaType, "audio/") {
links = append(links, MediaLink{URL: url, Type: "audio", Description: description})
} else if strings.HasPrefix(content.MediaType, "video/") {
links = append(links, MediaLink{URL: url, Type: "video", Description: description})
} else if content.MediaMedium == "image" || content.MediaMedium == "audio" || content.MediaMedium == "video" {
links = append(links, MediaLink{URL: url, Type: content.MediaMedium, Description: description})
} else {
if len(content.MediaThumbnails) > 0 {
links = append(links, MediaLink{
URL: content.MediaThumbnails[0].URL,
Type: "image",
})
}
}
}
for _, thumbnail := range content.MediaThumbnails {
links = append(links, MediaLink{
URL: thumbnail.URL,
Type: "image",
})
}
}
if len(links) == 0 {
return nil
}
return links
}

25
src/parser/models.go Normal file
View File

@@ -0,0 +1,25 @@
package parser
import "time"
type Feed struct {
Title string
SiteURL string
Items []Item
}
type Item struct {
GUID string
Date time.Time
URL string
Title string
Content string
MediaLinks []MediaLink
}
type MediaLink struct {
URL string
Type string
Description string
}

49
src/parser/rdf.go Normal file
View File

@@ -0,0 +1,49 @@
// Parser for RSS versions:
// - 0.90
// - 1.0
package parser
import (
"encoding/xml"
"io"
)
type rdfFeed struct {
XMLName xml.Name `xml:"RDF"`
Title string `xml:"channel>title"`
Link string `xml:"channel>link"`
Items []rdfItem `xml:"item"`
}
type rdfItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
}
func ParseRDF(r io.Reader) (*Feed, error) {
srcfeed := rdfFeed{}
decoder := xmlDecoder(r)
if err := decoder.Decode(&srcfeed); err != nil {
return nil, err
}
dstfeed := &Feed{
Title: srcfeed.Title,
SiteURL: srcfeed.Link,
}
for _, srcitem := range srcfeed.Items {
dstfeed.Items = append(dstfeed.Items, Item{
GUID: srcitem.Link,
URL: srcitem.Link,
Date: dateParse(srcitem.DublinCoreDate),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
})
}
return dstfeed, nil
}

81
src/parser/rdf_test.go Normal file
View File

@@ -0,0 +1,81 @@
package parser
import (
"reflect"
"strings"
"testing"
"time"
)
func TestRDFFeed(t *testing.T) {
have, _ := Parse(strings.NewReader(`<?xml version="1.0"?>
<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://channel.netscape.com/rdf/simple/0.9/">
<channel>
<title>Mozilla Dot Org</title>
<link>http://www.mozilla.org</link>
<description>the Mozilla Organization
web site</description>
</channel>
<image>
<title>Mozilla</title>
<url>http://www.mozilla.org/images/moz.gif</url>
<link>http://www.mozilla.org</link>
</image>
<item>
<title>New Status Updates</title>
<link>http://www.mozilla.org/status/</link>
</item>
<item>
<title>Bugzilla Reorganized</title>
<link>http://www.mozilla.org/bugs/</link>
</item>
</rdf:RDF>
`))
want := &Feed{
Title: "Mozilla Dot Org",
SiteURL: "http://www.mozilla.org",
Items: []Item{
{GUID: "http://www.mozilla.org/status/", URL: "http://www.mozilla.org/status/", Title: "New Status Updates"},
{GUID: "http://www.mozilla.org/bugs/", URL: "http://www.mozilla.org/bugs/", Title: "Bugzilla Reorganized"},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid rdf")
}
}
func TestRDFExtensions(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns="http://purl.org/rss/1.0/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<item>
<dc:date>2006-01-02T15:04:05-07:00</dc:date>
<content:encoded><![CDATA[test]]></content:encoded>
</item>
</rdf:RDF>
`))
date, _ := time.Parse(time.RFC1123Z, time.RFC1123Z)
want := &Feed{
Items: []Item{
{Content: "test", Date: date},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}

104
src/parser/rss.go Normal file
View File

@@ -0,0 +1,104 @@
// Parser for RSS versions:
// - 0.91 netscape
// - 0.91 userland
// - 2.0
package parser
import (
"encoding/xml"
"io"
"path"
"strings"
)
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Title string `xml:"channel>title"`
Link string `xml:"channel>link"`
Items []rssItem `xml:"channel>item"`
}
type rssItem struct {
GUID rssGuid `xml:"guid"`
Title string `xml:"title"`
Link string `xml:"rss link"`
Description string `xml:"rss description"`
PubDate string `xml:"pubDate"`
Enclosures []rssEnclosure `xml:"enclosure"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
OrigLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
media
}
type rssGuid struct {
GUID string `xml:",chardata"`
IsPermaLink string `xml:"isPermaLink,attr"`
}
type rssLink struct {
XMLName xml.Name
Data string `xml:",chardata"`
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr"`
}
type rssTitle struct {
XMLName xml.Name
Data string `xml:",chardata"`
Inner string `xml:",innerxml"`
}
type rssEnclosure struct {
URL string `xml:"url,attr"`
Type string `xml:"type,attr"`
Length string `xml:"length,attr"`
}
func ParseRSS(r io.Reader) (*Feed, error) {
srcfeed := rssFeed{}
decoder := xmlDecoder(r)
decoder.DefaultSpace = "rss"
if err := decoder.Decode(&srcfeed); err != nil {
return nil, err
}
dstfeed := &Feed{
Title: srcfeed.Title,
SiteURL: srcfeed.Link,
}
for _, srcitem := range srcfeed.Items {
mediaLinks := srcitem.mediaLinks()
for _, e := range srcitem.Enclosures {
if strings.HasPrefix(e.Type, "audio/") {
podcastURL := e.URL
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
podcastURL = srcitem.OrigEnclosureLink
}
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
break
}
}
permalink := ""
if srcitem.GUID.IsPermaLink == "true" {
permalink = srcitem.GUID.GUID
}
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
MediaLinks: mediaLinks,
})
}
return dstfeed, nil
}

288
src/parser/rss_test.go Normal file
View File

@@ -0,0 +1,288 @@
package parser
import (
"reflect"
"strings"
"testing"
)
func TestRSSFeed(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0"?>
<!DOCTYPE rss SYSTEM "http://my.netscape.com/publish/formats/rss-0.91.dtd">
<rss version="0.91">
<channel>
<language>en</language>
<description>???</description>
<link>http://www.scripting.com/</link>
<title>Scripting News</title>
<item>
<title>Title 1</title>
<link>http://www.scripting.com/one/</link>
<description>Description 1</description>
</item>
<item>
<title>Title 2</title>
<link>http://www.scripting.com/two/</link>
<description>Description 2</description>
</item>
</channel>
</rss>
`))
want := &Feed{
Title: "Scripting News",
SiteURL: "http://www.scripting.com/",
Items: []Item{
{
GUID: "http://www.scripting.com/one/",
URL: "http://www.scripting.com/one/",
Title: "Title 1",
Content: "Description 1",
},
{
GUID: "http://www.scripting.com/two/",
URL: "http://www.scripting.com/two/",
Title: "Title 2",
Content: "Description 2",
},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid rss")
}
}
func TestRSSMediaContentThumbnail(t *testing.T) {
// see: https://vimeo.com/channels/staffpicks/videos/rss
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<channel>
<item>
<title></title>
<media:content>
<media:player url="https://player.vimeo.com/video/527877676"/>
<media:credit role="author" scheme="urn:ebu"></media:credit>
<media:thumbnail height="540" width="960" url="https://i.vimeocdn.com/video/1092705247_960.jpg"/>
<media:title></media:title>
</media:content>
</item>
</channel>
</rss>
`))
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "https://i.vimeocdn.com/video/1092705247_960.jpg",
Type: "image",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestRSSWithLotsOfSpaces(t *testing.T) {
// https://pxlnv.com/: https://feedpress.me/pxlnv
feed, err := Parse(strings.NewReader(strings.ReplaceAll(`
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" media="screen" href="/~files/feed-premium.xsl"?>
<lotsofspaces>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:wfw="http://wellformedweb.org/CommentAPI/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
xmlns:feedpress="https://feed.press/xmlns"
xmlns:media="http://search.yahoo.com/mrss/"
version="2.0">
<channel>
<title>finally</title>
</channel>
</rss>
`, "<lotsofspaces>", strings.Repeat(" ", 500))))
if err != nil {
t.Fatal(err)
}
have := feed.Title
want := "finally"
if have != want {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestRSSPodcast(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<enclosure length="100500" type="audio/x-m4a" url="http://example.com/audio.ext"/>
</item>
</channel>
</rss>
`))
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatal("Invalid media links")
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "http://example.com/audio.ext",
Type: "audio",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestRSSOpusPodcast(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<enclosure length="100500" type="audio/opus" url="http://example.com/audio.ext"/>
</item>
</channel>
</rss>
`))
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatal("Invalid media links")
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "http://example.com/audio.ext",
Type: "audio",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
// found in: https://podcast.cscript.site/podcast.xml
func TestRSSPodcastDuplicated(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<content:encoded>
<![CDATA[ <audio src="http://example.com/audio.ext"></audio> ]]>
</content:encoded>
<enclosure length="100500" type="audio/x-m4a" url="http://example.com/audio.ext"/>
</item>
</channel>
</rss>
`))
have := feed.Items[0].Content
want := `<audio src="http://example.com/audio.ext"></audio>`
if want != have {
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", want, have)
}
if len(feed.Items[0].MediaLinks) != 0 {
t.Fatal("item media must be excluded if present in the content")
}
}
func TestRSSTitleHTMLTags(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<title>&lt;p&gt;title in p&lt;/p&gt;</title>
</item>
<item>
<title>very &lt;strong&gt;strong&lt;/strong&gt; title</title>
</item>
</channel>
</rss>
`))
have := []string{feed.Items[0].Title, feed.Items[1].Title}
want := []string{"title in p", "very strong title"}
for i := 0; i < len(want); i++ {
if want[i] != have[i] {
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
}
}
}
func TestRSSIsPermalink(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<guid isPermaLink="true">http://example.com/posts/1</guid>
</item>
</channel>
</rss>
`))
have := feed.Items
want := []Item{
{
GUID: "http://example.com/posts/1",
URL: "http://example.com/posts/1",
},
}
for i := 0; i < len(want); i++ {
if !reflect.DeepEqual(want, have) {
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
}
}
}
func TestRSSMultipleMedia(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<item>
<guid isPermaLink="true">http://example.com/posts/1</guid>
<media:content url="https://example.com/path/to/image1.png" type="image/png" fileSize="1000" medium="image">
<media:description type="plain">description 1</media:description>
</media:content>
<media:content url="https://example.com/path/to/image2.png" type="image/png" fileSize="2000" medium="image">
<media:description type="plain">description 2</media:description>
</media:content>
<media:content url="https://example.com/path/to/video1.mp4" type="video/mp4" fileSize="2000" medium="image">
<media:description type="plain">video description</media:description>
</media:content>
</item>
</channel>
</rss>
`))
have := feed.Items
want := []Item{
{
GUID: "http://example.com/posts/1",
URL: "http://example.com/posts/1",
MediaLinks: []MediaLink{
{URL:"https://example.com/path/to/image1.png", Type:"image", Description:"description 1"},
{URL:"https://example.com/path/to/image2.png", Type:"image", Description:"description 2"},
{URL:"https://example.com/path/to/video1.mp4", Type:"video", Description:"video description"},
},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid rss")
}
}

112
src/parser/util.go Normal file
View File

@@ -0,0 +1,112 @@
package parser
import (
"bufio"
"bytes"
"encoding/xml"
"io"
"regexp"
"strings"
"golang.org/x/net/html/charset"
)
func firstNonEmpty(vals ...string) string {
for _, val := range vals {
valTrimmed := strings.TrimSpace(val)
if len(valTrimmed) > 0 {
return valTrimmed
}
}
return ""
}
var linkRe = regexp.MustCompile(`(https?:\/\/\S+)`)
func plain2html(text string) string {
text = linkRe.ReplaceAllString(text, `<a href="$1">$1</a>`)
text = strings.ReplaceAll(text, "\n", "<br>")
return text
}
func xmlDecoder(r io.Reader) *xml.Decoder {
decoder := xml.NewDecoder(r)
decoder.Strict = false
decoder.CharsetReader = func(cs string, input io.Reader) (io.Reader, error) {
r, err := charset.NewReaderLabel(cs, input)
if err == nil {
r = NewSafeXMLReader(r)
}
return r, err
}
return decoder
}
type safexmlreader struct {
reader *bufio.Reader
buffer *bytes.Buffer
}
func NewSafeXMLReader(r io.Reader) io.Reader {
return &safexmlreader{
reader: bufio.NewReader(r),
buffer: bytes.NewBuffer(make([]byte, 0, 4096)),
}
}
func (xr *safexmlreader) Read(p []byte) (int, error) {
for xr.buffer.Len() < cap(p) {
r, _, err := xr.reader.ReadRune()
if err == io.EOF {
if xr.buffer.Len() == 0 {
return 0, io.EOF
}
break
}
if err != nil {
return 0, err
}
if isInCharacterRange(r) {
xr.buffer.WriteRune(r)
}
}
return xr.buffer.Read(p)
}
// NOTE: copied from "encoding/xml" package
// Decide whether the given rune is in the XML Character Range, per
// the Char production of https://www.xml.com/axml/testaxml.htm,
// Section 2.2 Characters.
func isInCharacterRange(r rune) (inrange bool) {
return r == 0x09 ||
r == 0x0A ||
r == 0x0D ||
r >= 0x20 && r <= 0xD7FF ||
r >= 0xE000 && r <= 0xFFFD ||
r >= 0x10000 && r <= 0x10FFFF
}
// NOTE: copied from "encoding/xml" package
// procInst parses the `param="..."` or `param='...'`
// value out of the provided string, returning "" if not found.
func procInst(param, s string) string {
// TODO: this parsing is somewhat lame and not exact.
// It works for all actual cases, though.
param = param + "="
idx := strings.Index(s, param)
if idx == -1 {
return ""
}
v := s[idx+len(param):]
if v == "" {
return ""
}
if v[0] != '\'' && v[0] != '"' {
return ""
}
idx = strings.IndexRune(v[1:], rune(v[0]))
if idx == -1 {
return ""
}
return v[1 : idx+1]
}

88
src/parser/util_test.go Normal file
View File

@@ -0,0 +1,88 @@
package parser
import (
"bytes"
"io"
"reflect"
"testing"
)
func TestSafeXMLReader(t *testing.T) {
var f io.Reader
want := []byte("привет мир")
f = bytes.NewReader(want)
f = NewSafeXMLReader(f)
have, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
}
}
func TestSafeXMLReaderRemoveUnwantedRunes(t *testing.T) {
var f io.Reader
input := []byte("\aпривет \x0cмир\ufffe\uffff")
want := []byte("привет мир")
f = bytes.NewReader(input)
f = NewSafeXMLReader(f)
have, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
}
}
func TestSafeXMLReaderPartial1(t *testing.T) {
var f io.Reader
input := []byte("\aпривет \x0cмир\ufffe\uffff")
want := []byte("привет мир")
f = bytes.NewReader(input)
f = NewSafeXMLReader(f)
buf := make([]byte, 1)
for i := 0; i < len(want); i++ {
n, err := f.Read(buf)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("expected 1 byte, got %d", n)
}
if buf[0] != want[i] {
t.Fatalf("invalid char at pos %d\nwant: %v\nhave: %v", i, want[i], buf[0])
}
}
if x, err := f.Read(buf); err != io.EOF {
t.Fatalf("expected EOF, %v, %v %v", buf, x, err)
}
}
func TestSafeXMLReaderPartial2(t *testing.T) {
var f io.Reader
input := []byte("привет\a\a\a\a\a")
f = bytes.NewReader(input)
f = NewSafeXMLReader(f)
buf := make([]byte, 12)
n, err := f.Read(buf)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if n != 12 {
t.Fatalf("expected 12 bytes")
}
n, err = f.Read(buf)
if n != 0 {
t.Fatalf("expected 0")
}
if err != io.EOF {
t.Fatalf("expected EOF, got %v", err)
}
}

View File

@@ -0,0 +1,14 @@
//go:build !windows
package platform
// On non-windows platforms, we don't need to do anything. The console
// starts off attached already, if it exists.
func AttachConsole() error {
return nil
}
func FixConsoleIfNeeded() error {
return nil
}

View File

@@ -0,0 +1,136 @@
//go:build windows
package platform
import (
"fmt"
"golang.org/x/sys/windows"
"os"
"syscall"
)
func AttachConsole() error {
const ATTACH_PARENT_PROCESS = ^uintptr(0)
proc := syscall.MustLoadDLL("kernel32.dll").MustFindProc("AttachConsole")
r1, _, err := proc.Call(ATTACH_PARENT_PROCESS)
if r1 == 0 {
errno, ok := err.(syscall.Errno)
if ok && errno == windows.ERROR_INVALID_HANDLE {
// console handle doesn't exist; not a real
// error, but the console handle will be
// invalid.
return nil
}
return err
} else {
return nil
}
}
var oldStdin, oldStdout, oldStderr *os.File
// Windows console output is a mess.
//
// If you compile as "-H windows", then if you launch your program without
// a console, Windows forcibly creates one to use as your stdin/stdout, which
// is silly for a GUI app, so we can't do that.
//
// If you compile as "-H windowsgui", then it doesn't create a console for
// your app... but also doesn't provide a working stdin/stdout/stderr even if
// you *did* launch from the console. However, you can use AttachConsole()
// to get a handle to your parent process's console, if any, and then
// os.NewFile() to turn that handle into a fd usable as stdout/stderr.
//
// However, then you have the problem that if you redirect stdout or stderr
// from the shell, you end up ignoring the redirection by forcing it to the
// console.
//
// To fix *that*, we have to detect whether there was a pre-existing stdout
// or not. We can check GetStdHandle(), which returns 0 for "should be
// console" and nonzero for "already pointing at a file."
//
// Be careful though! As soon as you run AttachConsole(), it resets *all*
// the GetStdHandle() handles to point them at the console instead, thus
// throwing away the original file redirects. So we have to GetStdHandle()
// *before* AttachConsole().
//
// For some reason, powershell redirections provide a valid file handle, but
// writing to that handle doesn't write to the file. I haven't found a way
// to work around that. (Windows 10.0.17763.379)
//
// Net result is as follows.
// Before:
//
// SHELL NON-REDIRECTED REDIRECTED
// explorer.exe no console n/a
// cmd.exe broken works
// powershell broken broken
// WSL bash broken works
//
// After
//
// SHELL NON-REDIRECTED REDIRECTED
// explorer.exe no console n/a
// cmd.exe works works
// powershell works broken
// WSL bash works works
//
// We don't seem to make anything worse, at least.
func FixConsoleIfNeeded() error {
// Retain the original console objects, to prevent Go from automatically
// closing their file descriptors when they get garbage collected.
// You never want to close file descriptors 0, 1, and 2.
oldStdin, oldStdout, oldStderr = os.Stdin, os.Stdout, os.Stderr
stdin, _ := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
stdout, _ := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
stderr, _ := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
var invalid syscall.Handle
con := invalid
if stdin == invalid || stdout == invalid || stderr == invalid {
err := AttachConsole()
if err != nil {
return fmt.Errorf("attachconsole: %v", err)
}
if stdin == invalid {
stdin, _ = syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
}
if stdout == invalid {
stdout, _ = syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
con = stdout
}
if stderr == invalid {
stderr, _ = syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
con = stderr
}
}
if con != invalid {
// Make sure the console is configured to convert
// \n to \r\n, like Go programs expect.
h := windows.Handle(con)
var st uint32
err := windows.GetConsoleMode(h, &st)
if err != nil {
return fmt.Errorf("GetConsoleMode: %v", err)
}
err = windows.SetConsoleMode(h, st&^windows.DISABLE_NEWLINE_AUTO_RETURN)
if err != nil {
return fmt.Errorf("SetConsoleMode: %v", err)
}
}
if stdin != invalid {
os.Stdin = os.NewFile(uintptr(stdin), "stdin")
}
if stdout != invalid {
os.Stdout = os.NewFile(uintptr(stdout), "stdout")
}
if stderr != invalid {
os.Stderr = os.NewFile(uintptr(stderr), "stderr")
}
return nil
}

View File

@@ -1,15 +1,16 @@
// +build macos windows //go:build (darwin || windows) && gui
package platform package platform
import ( import (
"github.com/getlantern/systray"
"github.com/nkanaev/yarr/src/server" "github.com/nkanaev/yarr/src/server"
"github.com/nkanaev/yarr/src/systray"
) )
func Start(s *server.Handler) { func Start(s *server.Server) {
systrayOnReady := func() { systrayOnReady := func() {
systray.SetIcon(Icon) systray.SetIcon(Icon)
systray.SetTooltip("yarr")
menuOpen := systray.AddMenuItem("Open", "") menuOpen := systray.AddMenuItem("Open", "")
systray.AddSeparator() systray.AddSeparator()

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