344 Commits

Author SHA1 Message Date
nkanaev
ce1c4863ee generic search without English stemming 2026-06-20 15:08:05 +01:00
nkanaev
e7004bbd29 more storage tests 2026-06-20 14:58:58 +01:00
nkanaev
72a2bf605b storage: postgres fixes 2026-06-20 14:58:58 +01:00
nkanaev
06bed5b556 storage: postgres per-test isolation 2026-06-20 14:58:58 +01:00
nkanaev
ba3034b3cf storage test fixes 2026-06-20 14:58:58 +01:00
nkanaev
671cb2b9e9 storage test fixes 2026-06-20 14:58:58 +01:00
nkanaev
15b6f9c566 storage test fixes 2026-06-20 14:58:58 +01:00
nkanaev
3ab2292eeb storage test fixes 2026-06-20 14:58:58 +01:00
nkanaev
a995dc7b7a storage test fixes 2026-06-20 14:58:44 +01:00
nkanaev
4dc266d3d3 storage test fixes 2026-06-20 14:58:44 +01:00
nkanaev
5110fbd596 storage test fixes 2026-06-20 14:58:44 +01:00
nkanaev
7de4879a96 storage test fixes 2026-06-20 14:58:44 +01:00
nkanaev
3e2b90f143 storage test fixes 2026-06-20 14:58:44 +01:00
nkanaev
4dbedb2f99 wrap tests in dbtest 2026-06-20 14:58:44 +01:00
nkanaev
32cfc3bc1a rename package name + test factory 2026-06-20 14:58:44 +01:00
nkanaev
a5b8e62ca7 move sqlite tests to separate package 2026-06-20 14:58:44 +01:00
nkanaev
c554650db9 postgres: rework search 2026-06-20 14:58:44 +01:00
nkanaev
3b42d8c703 wrap postgres errors 2026-06-20 14:58:44 +01:00
nkanaev
7b5c77f622 update makefile 2026-06-20 14:58:44 +01:00
nkanaev
ba9ddc99f0 fix 2026-06-20 14:58:44 +01:00
nkanaev
c452cdddf7 postgres migration tweaks 2026-06-20 14:58:44 +01:00
nkanaev
d4766429cf accept postgres url in New 2026-06-20 14:58:44 +01:00
nkanaev
5c2d9bfc4c ai: generate postgres package draft 2026-06-20 14:58:44 +01:00
nkanaev
eef482d81d add lib/pq 2026-06-20 14:58:44 +01:00
nkanaev
78a45c8533 Update readme.md 2026-06-20 10:41:10 +01:00
nkanaev
f2556178b3 add storage interface, fix all references 2026-06-10 22:24:04 +01:00
nkanaev
3f10371975 fix references 2026-06-09 16:35:14 +01:00
nkanaev
dee386b586 move structs to model 2026-06-09 16:05:56 +01:00
nkanaev
dc836ed4fd rename Storage struct to SQLiteStorage 2026-06-07 23:17:07 +01:00
nkanaev
76adcf0d62 rename package names 2026-06-07 23:15:37 +01:00
nkanaev
f29ad0c20a create sqlite package 2026-06-07 23:14:16 +01:00
nkanaev
14835660fb interface 2026-06-07 23:01:48 +01:00
nkanaev
d30124bf3c run vacuum after deleting old items 2026-06-03 13:59:47 +01:00
nkanaev
138b5ad991 switch to fts5, rework search syncing 2026-06-03 13:56:02 +01:00
nkanaev
2f263e9803 rewrite favicon discovery logic 2026-05-18 21:51:59 +01:00
nkanaev
76529c895e rewrite settings 2026-05-18 21:38:39 +01:00
nkanaev
847ec3861a feedstate fixes 2026-05-18 20:18:33 +01:00
nkanaev
85f3956b24 refactor feedstate + swap implementation 2026-05-18 20:06:41 +01:00
nkanaev
7553824520 feedstate: implement + test 2026-05-15 15:53:13 +01:00
nkanaev
54e197ad85 feed state skeleton 2026-05-15 15:10:25 +01:00
nkanaev
f50894ddb0 refactor folder update in storage 2026-05-11 13:36:51 +01:00
nkanaev
59af8aa62d use CreateFeedParams for CreateFeed 2026-05-11 11:03:41 +01:00
nkanaev
31274d17a5 use nullable for field updates 2026-05-11 10:27:50 +01:00
nkanaev
450f64605e refactor feed updating 2026-05-11 09:59:21 +01:00
nkanaev
391e2dd2c8 add fever api docs 2026-05-10 22:19:30 +01:00
nkanaev
8fc01db275 remove filter in CountItems 2026-05-10 22:18:37 +01:00
nkanaev
76c2b9a475 add russian 2026-05-01 23:48:27 +01:00
nkanaev
14d5a6b52b ui tweaks / fixes 2026-05-01 23:47:17 +01:00
nkanaev
6069330e92 i18n in UI 2026-05-01 23:35:14 +01:00
nkanaev
552ebb7ad5 i18n class 2026-05-01 22:46:52 +01:00
Wes Koop
74e6ee8e8e Do not add filter for root folder, allowing ALL feeds to be marked as read.
Reeder Fever behavious is to send an id=0 when you mark all items as read
2026-04-27 22:01:12 +01:00
nkanaev
167aef9ba1 remove feed_sizes 2026-04-27 21:51:12 +01:00
nkanaev
ed726f26f4 change DeleteOldItems logic 2026-04-27 21:41:56 +01:00
nkanaev
760f611007 add item.last_arrived field 2026-04-27 21:05:25 +01:00
nkanaev
49c704037b cmd: modernize -fix ./cmd/... 2026-04-27 20:44:24 +01:00
nkanaev
7a5f8a5e41 use template icon on macos 2026-04-27 13:49:06 +01:00
nkanaev
1bae41a350 switch to fyne.io/systray 2026-04-25 22:57:32 +01:00
nkanaev
f1bdbbc0af golines -w src 2026-04-25 22:45:43 +01:00
nkanaev
f01c26b2c2 sanitize ./... 2026-04-25 22:45:43 +01:00
nkanaev
cbe1f971a5 refactor: use sql named arg 2026-04-25 22:45:43 +01:00
wes koop
1d654ac4de Account for client timezone on Fever API 2026-03-21 17:54:14 +00:00
nkanaev
55b9b4a38b build whenever 2026-03-16 21:18:35 +00:00
charlie
e916fdbe6c fix: add QEMU and Buildx setup for multi-arch Docker builds
Fixes #290

The workflow was failing because it specified multi-platform builds
(linux/amd64,linux/arm64) but didn't set up QEMU emulation or Buildx.

This adds the required setup steps before the build action.
2026-03-16 21:18:35 +00:00
nkanaev
0e3df33d1f parse rss 2.0 image enclosures 2026-03-16 20:44:46 +00:00
nkanaev
506fe1cae6 update changelog 2026-01-23 20:09:05 +00:00
nkanaev
1d97314825 declare default namespace explicitly 2026-01-23 20:04:29 +00:00
nkanaev
e1ecb6760b Update changelog.md 2025-12-28 14:16:23 +00:00
rksvc
953f560a11 fix crash on empty item list with item selected 2025-12-28 14:13:46 +00:00
nkanaev
3d69911aa8 reset article list right after filter/feed is selected 2025-12-11 11:22:14 +00:00
nkanaev
1052735535 v2.6 2025-11-24 21:10:28 +00:00
nkanaev
d6504ac2e9 update promo image 2025-11-24 20:59:15 +00:00
nkanaev
2a25f934c5 various ui fixes 2025-11-24 20:51:03 +00:00
nkanaev
16a7f3409c youtube shorts in readability 2025-10-06 14:39:23 +01:00
nkanaev
0e11cec99a remove print statements 2025-10-06 14:39:23 +01:00
Nadia Santalla
c158912da4 fix media_links reading from DB
Prior to this commit, `MediaLinks` were always returned as `nil`.
Peeking a bit I figured that's becuase the argument to `MediaLinks.Scan`
is in fact a string, and not a `[]byte` as the code expects. I guess
that might be because `media_links` is a `json` (not `jsonb`) column in
sqlite. I have no idea which of the two is best to use for the DB side,
but it's easy to make the code support both.
2025-10-06 14:18:03 +01:00
nkanaev
08ad04401d Update changelog.md 2025-10-02 19:31:37 +01:00
nkanaev
a851d8ac9d minor ui tweaks 2025-10-02 19:31:37 +01:00
Your Name
5a3547e32e host build for openbsd 2025-10-02 10:26:44 +01:00
Your Name
b24152c19a fix mustHideFolder 2025-10-02 10:23:29 +01:00
nkanaev
9f93298cf9 restrict private IP access 2025-10-02 10:16:35 +01:00
Adam Simpson
ac9b635ed8 app: add support for theme-color
I use the "web app" version of yarr on my iPhone and the area around the
notch/island is un-themed.

Using [theme-color][1] we can control that color.

[1]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/theme-color
2025-09-25 14:28:15 +01:00
nkanaev
72a1930b9e rewrite feed navigation 2025-09-24 23:07:26 +01:00
nkanaev
e339354cc9 keyboard shortcut to close article 2025-09-23 22:08:04 +01:00
nkanaev
4b3a278679 Update changelog.md 2025-09-23 21:59:32 +01:00
nkanaev
aa06e65c59 redesign auto-refresh UI 2025-09-23 21:59:32 +01:00
nkanaev
dd57abefdd Update .gitignore 2025-09-23 21:59:32 +01:00
nkanaev
be8ba62bb1 make manifest.json public 2025-09-23 21:59:32 +01:00
nkanaev
b7895f6743 auth cookie directives 2025-09-23 21:59:32 +01:00
daigennki
ebe7b130b8 Add more categories to desktop shortcut
Ensures that it doesn't end up in "Lost & Found" on KDE Plasma's "Applications" menu.
2025-08-20 21:10:36 +01:00
nkanaev
7fe688e97c smooth scrolling on iOS 2025-06-04 22:11:50 +01:00
Bernhard Fröhlich
6b02a09f75 Update open_etc.go
Fix build on FreeBSD
2025-06-04 21:54:44 +01:00
Jason Rogena
f0d2ab6493 Add support for ARM64 Docker images
Commit updates the build-docker workflow to add suport for ARM64 images.

Testing
=======

Locally, I attempted to build an image from the provided Dockerfile in an
ARM64 host using:

```sh
podman build -t yarr:latest -f etc/dockerfile .
```

The `platforms` input is provided as documented in [1].

1 - https://github.com/docker/build-push-action?tab=readme-ov-file#inputs

Signed-off-by: Jason Rogena <null+git@rogena.me>
2025-04-28 11:50:19 +01:00
dependabot[bot]
42ee0372fe Bump golang.org/x/net from 0.37.0 to 0.38.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.37.0...v0.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-17 15:55:22 +01:00
nkanaev
9762e09cb3 Update changelog.txt 2025-03-27 22:24:14 +00:00
Rohit Vighne
dd8b7ab27d Listen on AF_UNIX socket if -addr is a path 2025-03-27 22:22:20 +00:00
nkanaev
c348593ef4 Update readme.md 2025-03-27 09:38:18 +00:00
nkanaev
a51da7b8ec Update readme.md 2025-03-26 15:11:42 +00:00
nkanaev
33503f7896 update changelog 2025-03-26 14:53:04 +00:00
nkanaev
da569b3321 bump version 2025-03-26 14:42:39 +00:00
nkanaev
11285e4af0 update docker publish workflow 2025-03-26 14:42:15 +00:00
nkanaev
9fe02931d8 update dockerfiles 2025-03-26 14:33:23 +00:00
nkanaev
e4f9dc8c72 sanitize media description in html 2025-03-26 12:39:36 +00:00
nkanaev
88ed1de58b update go version 2025-03-24 02:34:52 +00:00
nkanaev
9bc89123f8 go fmt 2025-03-24 02:33:30 +00:00
nkanaev
9fb3da2b4a update dependencies 2025-03-24 02:32:57 +00:00
nkanaev
58bb2c22c3 rewrite migration 2025-03-24 02:27:07 +00:00
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
395 changed files with 507851 additions and 3259 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

49
.github/workflows/build-docker.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Publish Docker Image
on:
push:
tags:
- v*
workflow_dispatch:
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@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./etc/dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

View File

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

6
.gitignore vendored
View File

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

View File

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

View File

@@ -1,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)
}

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

@@ -0,0 +1,161 @@
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)
if 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]
}
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 open && strings.HasPrefix(addr, "unix:") {
log.Fatal("Cannot open ", addr, " in browser")
}
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.23
* 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"

132
doc/changelog.md Normal file
View File

@@ -0,0 +1,132 @@
# upcoming
- (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report)
- (fix) crash on empty article list with article is selected (thanks to @rksvc)
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
# v2.6 (2025-11-24)
- (new) serve on unix socket (thanks to @rvighne)
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
- (fix) smooth scrolling on iOS (thanks to gatheraled)
- (fix) displaying youtube shorts in "Read Here" (thanks to @Dean-Corso for the report)
- (etc) theme-color support (thanks to @asimpson)
- (etc) cookie security measures (thanks to Tom Fitzhenry)
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
# v2.5 (2025-03-26)
- (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
- (new) next/prev article navigation buttons (thanks to @tillcash)
- (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)
- (etc) accessibility improvements (thanks to @tseykovets)
# 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

View File

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

254
doc/fever-api.md Normal file
View File

@@ -0,0 +1,254 @@
# API Public Beta
Fever 1.14 introduces the new Fever API. This API is in public beta and currently supports basic syncing and consuming of content. A subsequent update will allow for adding, editing and deleting feeds and groups. The APIs primary focus is maintaining a local cache of the data in a remote Fever installation.
I am [soliciting feedback](https://web.archive.org/web/20221221112459/https://feedafever.com/contact) from interested developers and as such the beta API may expand to reflect that feedback. The current API is incomplete but stable. Existing features may be expanded on but will not be removed or modified. New features may be added.
Ive created a [simple HTML widget](https://web.archive.org/web/20221221112459/https://feedafever.com/gateway/public/api-widget.html.zip) that allows you to query the Fever API and view the response.
## Authentication
Without further ado, the Fever API endpoint URL looks like:
```
http://yourdomain.com/fever/?api
```
All requests must be authenticated with a `POST`ed `api_key`. The value of `api_key` should be the md5 checksum of the Fever accounts email address and password concatenated with a colon. An example of a valid value for `api_key` using PHPs native `md5()` function:
```
$email = 'you@yourdomain.com';
$pass = 'b3stp4s4wd3v4';
$api_key = md5($email.':'.$pass);
```
A user may specify that `https` be used to connect to their Fever installation for additional security but you should not assume that all Fever installations support `https`.
The default response is a JSON object containing two members:
- `api_version` contains the version of the API responding (positive integer)
- `auth` whether the request was successfully authenticated (boolean integer)
The API can also return XML by passing `xml` as the optional value of the `api` argument like so:
```
http://yourdomain.com/fever/?api=xml
```
The top level XML element is named `response`.
The response to each successfully authenticated request will have `auth` set to `1` and include at least one additional member:
- `last_refreshed_on_time` contains the time of the most recently refreshed (not _updated_) feed (Unix timestamp/integer)
## Read
When reading from the Fever API you add arguments to the query string of the API endpoint URL. If you attempt to `POST` these arguments (and their optional values) Fever will not recognize the request.
### Groups
```
http://yourdomain.com/fever/?api&groups
```
A request with the `groups` argument will return two additional members:
- `groups` contains an array of `group` objects
- `feeds_groups` contains an array of `feeds_group` objects
A `group` object has the following members:
- `id` (positive integer)
- `title` (utf-8 string)
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
The “Kindling” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `0`. The “Sparks” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `1`.
### Feeds
```
http://yourdomain.com/fever/?api&feeds
```
A request with the `feeds` argument will return two additional members:
- `feeds` contains an array of `group` objects
- `feeds_groups` contains an array of `feeds_group` objects
A `feed` object has the following members:
- `id` (positive integer)
- `favicon_id` (positive integer)
- `title` (utf-8 string)
- `url` (utf-8 string)
- `site_url` (utf-8 string)
- `is_spark` (boolean integer)
- `last_updated_on_time` (Unix timestamp/integer)
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
The “All Items” super feed is not included in this response and is composed of all items from all feeds that belong to a given group. For the “Kindling” super group and all user created groups the items should be limited to feeds with an `is_spark` equal to `0`. For the “Sparks” super group the items should be limited to feeds with an `is_spark` equal to `1`.
### Feeds/Groups Relationships
A request with either the `groups` or `feeds` arguments will return an additional member:
A `feeds_group` object has the following members:
- `group_id` (positive integer)
- `feed_ids` (string/comma-separated list of positive integers)
### Favicons
```
http://yourdomain.com/fever/?api&favicons
```
A request with the `favicons` argument will return one additional member:
- `favicons` contains an array of `favicon` objects
A `favicon` object has the following members:
- `id` (positive integer)
- `data` (base64 encoded image data; prefixed by image type)
An example `data` value:
```
image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
```
The `data` member of a `favicon` object can be used with the `data:` protocol to embed an image in CSS or HTML. A PHP/HTML example:
```
echo '<img src="data:'.$favicon['data'].'">';
```
### Items
```
http://yourdomain.com/fever/?api&items
```
A request with the `items` argument will return two additional members:
- `items` contains an array of `item` objects
- `total_items` contains the total number of items stored in the database (added in API version 2)
An `item` object has the following members:
- `id` (positive integer)
- `feed_id` (positive integer)
- `title` (utf-8 string)
- `author` (utf-8 string)
- `html` (utf-8 string)
- `url` (utf-8 string)
- `is_saved` (boolean integer)
- `is_read` (boolean integer)
- `created_on_time` (Unix timestamp/integer)
Most servers wont have enough memory allocated to PHP to dump all items at once. Three optional arguments control determine the items included in the response.
- Use the `since_id` argument with the highest id of locally cached items to request 50 additional items. Repeat until the `items` array in the response is empty.
- Use the `max_id` argument with the lowest id of locally cached items (or `0` initially) to request 50 previous items. Repeat until the `items` array in the response is empty. (added in API version 2)
- Use the `with_ids` argument with a comma-separated list of item ids to request (a maximum of 50) specific items. (added in API version 2)
### Hot Links
```
http://yourdomain.com/fever/?api&links
```
A request with the `links` argument will return one additional member:
- `links` contains an array of `link` objects
A `link` object has the following members:
- `id` (positive integer)
- `feed_id` (positive integer) only use when `is_item` equals `1`
- `item_id` (positive integer) only use when `is_item` equals `1`
- `temperature` (positive float)
- `is_item` (boolean integer)
- `is_local` (boolean integer) used to determine if the source feed and favicon should be displayed
- `is_saved` (boolean integer) only use when `is_item` equals `1`
- `title` (utf-8 string)
- `url` (utf-8 string)
- `item_ids` (string/comma-separated list of positive integers)
When requesting hot links you can control the range and offset by specifying a length of days for each as well as a page to fetch additional hot links. A request with just the `links` argument is equivalent to:
```
http://yourdomain.com/fever/?api&links&offset=0&range=7&page=1
```
Or the first page (`page=1`) of Hot links for the past week (`range=7`) starting now (`offset=0`).
### Link Caveats
Fever calculates Hot link temperatures in real-time. The API assumes you have an up-to-date local cache of items, feeds and favicons with which to construct a meaningful Hot view. Because they are ephemeral Hot links should not be cached in the same relational manner as items, feeds, groups and favicons.
Because Fever saves items and not individual links you can only "save" a Hot link when `is_item` equals `1`.
## Sync
The `unread_item_ids` and `saved_item_ids` arguments can be used to keep your local cache synced with the remote Fever installation.
```
http://yourdomain.com/fever/?api&unread_item_ids
```
A request with the `unread_item_ids` argument will return one additional member:
- `unread_item_ids` (string/comma-separated list of positive integers)
```
http://yourdomain.com/fever/?api&saved_item_ids
```
A request with the `saved_item_ids` argument will return one additional member:
- `saved_item_ids` (string/comma-separated list of positive integers)
One of these members will be returned as appropriate when marking an item as read, unread, saved, or unsaved and when marking a feed or group as read.
Because groups and feeds will be limited in number compared to items, they should be synced by comparing an array of locally cached feed or group ids to an array of feed or group ids returned by their respective API request.
## Write
The public beta of the API does not provide a way to add, edit or delete feeds or groups but you can mark items, feeds and groups as read and save or unsave items. You can also unread recently read items. When writing to the Fever API you add arguments to the `POST` data you submit to the API endpoint URL.
Adding `unread_recently_read=1` to your `POST` data will mark recently read items as unread.
You can update an individual item by adding the following three arguments to your `POST` data:
- `mark=item`
- `as=?` where `?` is replaced with `read`, `saved` or `unsaved`
- `id=?` where `?` is replaced with the `id` of the item to modify
Marking a feed or group as read is similar but requires one additional argument to prevent marking new, unreceived items as read:
- `mark=?` where `?` is replaced with `feed` or `group`
- `as=read`
- `id=?` where `?` is replaced with the `id` of the feed or group to modify
- `before=?` where `?` is replaced with the Unix timestamp of the the local clients most recent `items` API request
You can mark the “Kindling” super group (and the “Sparks” super group) as read by adding the following four arguments to your `POST` data:
- `mark=group`
- `as=read`
- `id=0`
- `before=?` where `?` is replaced with the Unix timestamp of the the local clients last `items` API request
Similarly you can mark just the “Sparks” super group as read by adding the following four arguments to your `POST` data:
- `mark=group`
- `as=read`
- `id=-1`
- `before=?` where `?` is replaced with the Unix timestamp of the the local clients last `items` API request

1755
doc/fever-api.mhtml Normal file

File diff suppressed because it is too large Load Diff

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.

View File

@@ -159,6 +159,7 @@ Delete any from the list in case they drop support of web feeds.
- medium
- posthaven
- reddit
- substack
- tumblr
- vimeo
- wordpress

View File

@@ -16,7 +16,7 @@ The licenses are included, and the authorship comments are left intact.
- allowed uri schemes
- added svg whitelist
- systray
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
- fixconsole
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
removed golog dependency
removed `w32` dependency

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.21 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.24.1.linux-amd64.tar.gz && \
rm -rf /usr/local/go && \
tar -C /usr/local -xzf go1.24.1.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;Network;News;Feed;
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: 335 KiB

68
etc/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]

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"

17
go.mod
View File

@@ -1,9 +1,18 @@
module github.com/nkanaev/yarr
go 1.16
go 1.23.0
toolchain go1.23.5
require (
github.com/mattn/go-sqlite3 v1.14.0
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13
fyne.io/systray v1.12.0
github.com/lib/pq v1.12.3
github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/net v0.38.0
golang.org/x/sys v0.31.0
)
require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
golang.org/x/text v0.23.0 // indirect
)

29
go.sum
View File

@@ -1,15 +1,14 @@
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
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=
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-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-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=

107
makefile
View File

@@ -1,39 +1,92 @@
VERSION=2.0
VERSION=2.6
GITHASH=$(shell git rev-parse --short=8 HEAD)
CGO_ENABLED=1
GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
GO_LDFLAGS = -s -w
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
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:
mkdir -p _output
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
export CGO_ENABLED=1
build_macos:
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)"
default: test host
build_linux:
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
# platform-specific files
build_windows:
set GOOS=windows
set GOARCH=386
mkdir -p _output/windows
go run bin/generate_versioninfo.go -version "$(VERSION)" -outfile src/platform/versioninfo.rc
etc/icon.icns: etc/icon_macos.png
mkdir -p etc/icon.iconset
sips -s format png --resampleWidth 1024 etc/icon_macos.png --out etc/icon.iconset/icon_512x512@2x.png
sips -s format png --resampleWidth 512 etc/icon_macos.png --out etc/icon.iconset/icon_512x512.png
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
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
YARR_DB ?= local.db
serve:
go run -tags "sqlite_foreign_keys" src/main.go -db local.db
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db "$(YARR_DB)"
test:
cd src && go test -tags "sqlite_foreign_keys release" ./...
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 serve_postgres test

View File

@@ -3,72 +3,38 @@
**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.
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)
Subscribe: [releases](https://github.com/nkanaev/yarr/releases.atom) / [devlog](https://hachyderm.io/@nkanaev.rss) ([Mastodon](https://hachyderm.io/@nkanaev))
## usage
The latest prebuilt binaries for Linux/MacOS/Windows are available
[here](https://github.com/nkanaev/yarr/releases/latest).
The archives follow the naming convention `yarr_{OS}_{ARCH}[_gui].zip`, where:
### macos
* `OS` is the target operating system
* `ARCH` is the CPU architecture (`arm64` for AArch64, `amd64` for X86-64)
* `-gui` indicates that the binary ships with the GUI (tray icon), and is a command line application if omitted
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder.
Usage instructions:
The binaries are not signed, because the author doesn't want to buy a certificate.
Apple hates cheapskate developers, therefore the OS will refuse to run the application.
To bypass these measures, you can run the command:
* MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
xattr -d com.apple.quarantine /Applications/yarr.app
* Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
### windows
* Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
Download `yarr-*-windows32.zip`, unzip it, place wherever you'd like to
(`C:\Program Files` or Recycle Bin). Create a shortcut manually if you'd like to.
Microsoft doesn't like cheapskate developers too,
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;
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
For self-hosting, see `yarr -h` for auth, tls & server configuration flags.
## build
See more:
Install `Go >= 1.16` and `gcc`. Get the source code:
git clone https://github.com/nkanaev/yarr.git
Then run one of the corresponding commands:
# create an executable for the host os
make build_macos # -> _output/macos/yarr.app
make build_linux # -> _output/linux/yarr
make build_windows # -> _output/windows/yarr.exe
# ... or start a dev server locally
make serve # starts a server at http://localhost:7070
# ... or build a docker image
docker build -t yarr .
* [Building from source code](doc/build.md)
* [Fever API support](doc/fever.md)
## credits

View File

@@ -5,7 +5,7 @@ import (
"html/template"
"io"
"io/fs"
"io/ioutil"
"log"
"os"
)
@@ -29,9 +29,18 @@ func Template(path string) *template.Template {
if !found {
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
"inline": func(svg string) template.HTML {
svgfile, _ := FS.Open("graphicarts/" + svg)
content, _ := ioutil.ReadAll(svgfile)
svgfile.Close()
svgfile, err := FS.Open("graphicarts/" + svg)
// should never happen
if err != nil {
log.Fatal(err)
}
defer svgfile.Close()
content, err := io.ReadAll(svgfile)
// should never happen
if err != nil {
log.Fatal(err)
}
return template.HTML(content)
},
}).ParseFS(FS, path))
@@ -42,7 +51,7 @@ func Template(path string) *template.Template {
return tmpl
}
func Render(path string, writer io.Writer, data interface{}) {
func Render(path string, writer io.Writer, data any) {
tmpl := Template(path)
tmpl.Execute(writer, data)
}

View File

@@ -1,4 +1,4 @@
// +build release
//go:build !debug
package assets

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-chevron-up"><polyline points="18 15 12 9 6 15"></polyline></svg>

Before

Width:  |  Height:  |  Size: 341 B

After

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

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-menu"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>

Before

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

@@ -5,7 +5,10 @@
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/icon.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="theme-color" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<script>
window.app = window.app || {}
@@ -21,61 +24,86 @@
<div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div>
<button class="toolbar-item"
<button class="toolbar-item ml-1"
:class="{active: filterSelected == 'unread'}"
title="Unread"
:aria-pressed="filterSelected == 'unread'"
:title="$t('unread')"
@click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span>
</button>
<button class="toolbar-item"
<button class="toolbar-item mx-1"
:class="{active: filterSelected == 'starred'}"
title="Starred"
:aria-pressed="filterSelected == 'starred'"
:title="$t('starred')"
@click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span>
</button>
<button class="toolbar-item"
<button class="toolbar-item mr-1"
:class="{active: filterSelected == ''}"
title="All"
:aria-pressed="filterSelected == ''"
:title="$t('all')"
@click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span>
</button>
<div class="flex-grow-1"></div>
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<button class="dropdown-item" @click="showSettings('create')">
<span class="icon mr-1">{% inline "plus.svg" %}</span>
New Feed
{{ $t('new_feed') }}
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="fetchAllFeeds()">
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds
{{ $t('refresh_feeds') }}
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Auto Refresh</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</header>
<div class="row text-center m-0">
<button class="dropdown-item col-4 px-0" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
<button class="btn btn-link col-4 px-0 rounded-0"
:class="'theme-'+t"
:aria-label="t"
: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>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Show first</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
<div class="row text-center m-0">
<button class="dropdown-item col-4 px-0"
@click.stop="changeRefreshRate(-1)"
:disabled="!refreshRate">
<span class="icon">
{% inline "chevron-down.svg" %}
</span>
</button>
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
<button class="dropdown-item col-4 px-0"
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
<span class="icon">
{% inline "chevron-up.svg" %}
</span>
</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('show_first') }}</header>
<div class="d-flex text-center">
<button class="dropdown-item px-0" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
<button class="dropdown-item px-0" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Subscriptions</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
<input type="file"
id="opml-import"
@@ -84,42 +112,50 @@
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
<span class="icon mr-1">{% inline "download.svg" %}</span>
Import
{{ $t('import') }}
</label>
</form>
<a class="dropdown-item" href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span>
Export
{{ $t('export') }}
</a>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="showSettings('shortcuts')">
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
Shortcuts
{{ $t('shortcuts') }}
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header>
<button
v-for="lang in languages"
class="dropdown-item"
:class="{active: language==lang.code}"
@click.stop="changeLanguage(lang.code)">
{{ lang.name }}
</button>
<div class="dropdown-divider" v-if="authenticated"></div>
<button class="dropdown-item" v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
{{ $t('log_out') }}
</button>
</dropdown>
</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 scroll-touch border-top flex-grow-1">
<label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2">{% inline "layers.svg" %}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('all_unread') }}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">{{ $t('all_starred') }}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
<span class="counter text-right">{{ filteredTotalStats }}</span>
</div>
</label>
<div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1"
:class="{'d-none': filterSelected
&& !filteredFolderStats[folder.id]
&& (!itemSelectedDetails || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
:class="{'d-none': mustHideFolder(folder)}"
v-if="folder.id">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2"
:class="{expanded: folder.is_expanded}"
@@ -132,9 +168,7 @@
</label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup"
:class="{'d-none': filterSelected
&& !filteredFeedStats[feed.id]
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
:class="{'d-none': mustHideFeed(feed)}"
v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
@@ -154,7 +188,7 @@
</div>
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
<span class="icon loading mx-2"></span>
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
<span class="text-truncate cursor-default noselect">{{ $t('refreshing') }} ({{ loading.feeds }} {{ $t('left') }})</span>
</div>
</div>
<!-- item list -->
@@ -163,7 +197,7 @@
<div class="px-2 toolbar d-flex align-items-center">
<button class="toolbar-item mr-2 d-block d-md-none"
@click="feedSelected = null"
title="Show Feeds">
:title="$t('show_feeds')">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<div class="input-icon flex-grow-1">
@@ -174,33 +208,42 @@
<button class="toolbar-item ml-2"
@click="markItemsRead()"
v-if="filterSelected == 'unread'"
title="Mark All Read">
:title="$t('mark_all_read')">
<span class="icon">{% inline "check.svg" %}</span>
</button>
<button class="btn btn-link toolbar-item px-2 ml-2" v-if="!current.type" disabled>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</button>
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
drop="right"
title="Feed Settings"
v-if="!filterSelected && current.type == 'feed'">
:title="$t('feed_settings')"
v-if="current.type == 'feed'">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<header class="dropdown-header">{{ current.feed.title }}</header>
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link">
<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
{{ $t('website') }}
</a>
<a class="dropdown-item" :href="current.feed.feed_link" target="_blank" v-if="current.feed.feed_link">
<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
{{ $t('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
{{ $t('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>
{{ $t('change_link') }}
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header">Move to...</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
<button class="dropdown-item"
v-if="folder.id != current.feed.folder_id"
v-for="folder in folders"
@@ -214,53 +257,53 @@
</button>
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
new folder
{{ $t('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
{{ $t('delete') }}
</button>
</dropdown>
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
title="Folder Settings"
:title="$t('folder_settings')"
drop="right"
v-if="!filterSelected && current.type == 'folder'">
v-if="current.type == 'folder'">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<header class="dropdown-header">{{ current.folder.title }}</header>
<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
{{ $t('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
{{ $t('delete') }}
</button>
</dropdown>
</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 scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id"
class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
<div class="selectgroup-label d-flex flex-column">
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<div style="line-height: 100%; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<transition name="indicator">
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition>
<small class="flex-fill text-truncate mr-1">
{{ feedsById[item.feed_id].title }}
{{ (feedsById[item.feed_id] || {}).title }}
</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>{{ item.title || 'untitled' }}</div>
<div>{{ item.title || $t('untitled') }}</div>
</div>
</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] }}
@@ -271,32 +314,24 @@
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
<button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)"
title="Mark Starred">
:title="$t('mark_starred')">
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
</button>
<button class="toolbar-item"
title="Mark Unread"
:title="$t('mark_unread')"
@click="toggleItemRead(itemSelectedDetails)">
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
</button>
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
<template v-slot:button>
<span class="icon">{% inline "sliders.svg" %}</span>
</template>
<div class="row text-center m-0">
<button class="btn btn-link col-4 px-0 rounded-0"
:class="'theme-'+t"
@click.stop="theme.name = t"
v-for="t in ['light', 'sepia', 'night']">
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
</button>
</div>
<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>
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
<div class="d-flex text-center">
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
@@ -306,33 +341,51 @@
<button class="toolbar-item"
:class="{active: itemSelectedReadability}"
@click="toggleReadability()"
title="Read Here">
:title="$t('read_here')">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button>
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" title="Open Link">
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
<span class="icon">{% inline "external-link.svg" %}</span>
</a>
<div class="flex-grow-1"></div>
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
<span class="icon">{% inline "chevron-right.svg" %}</span>
</button>
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
<span class="icon">{% inline "x.svg" %}</span>
</button>
</div>
<div v-if="itemSelectedDetails"
ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto"
class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
:style="{'font-size': theme.size + 'rem'}">
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
<div class="text-muted">
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div>
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
<div class="content-wrapper">
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
<div class="text-muted">
<div>
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}
</span>
</div>
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
</div>
<hr>
<div v-if="!itemSelectedReadability">
<div v-if="contentImages.length">
<figure v-for="media in contentImages">
<img :src="media.url" loading="lazy">
<figcaption v-if="media.description">{{ 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>
<hr>
<div v-if="!itemSelectedReadability">
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
</div>
<div v-html="itemSelectedContent"></div>
</div>
</div>
<modal :open="!!settings" @hide="settings = ''">
@@ -340,22 +393,22 @@
<span class="icon">{% inline "x.svg" %}</span>
</button>
<div v-if="settings=='create'">
<p class="cursor-default"><b>New Feed</b></p>
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
<label for="feed-url">URL</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0">
<label for="feed-url">{{ $t('url') }}</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
<label for="feed-folder" class="mt-3 d-block">
Folder
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
{{ $t('folder') }}
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
</label>
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
<option value="">---</option>
<option :value="folder.id" v-for="folder in folders">{{ folder.title }}</option>
<option :value="folder.id" v-for="folder in folders" :selected="folder.id === current.feed.folder_id || folder.id === current.folder.id">{{ folder.title }}</option>
</select>
<div class="mt-4" v-if="feedNewChoice.length">
<p class="mb-2">
Multiple feeds found. Choose one below:
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
{{ $t('multiple_feeds_found') }}
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
</p>
<label class="selectgroup" v-for="choice in feedNewChoice">
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
@@ -365,28 +418,29 @@
</div>
</label>
</div>
<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">{{ $t('add') }}</button>
</form>
</div>
<div v-else-if="settings=='shortcuts'">
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
<table class="table table-borderless table-sm table-compact m-0">
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
<td>show unread / starred / all feeds</td></tr>
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
<td>{{ $t('kb_show_filters') }}</td></tr>
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
</tr>
</table>
</div>
@@ -395,6 +449,7 @@
<!-- external -->
<script src="./static/javascripts/vue.min.js"></script>
<!-- internal -->
<script src="./static/javascripts/i18n.js"></script>
<script src="./static/javascripts/api.js"></script>
<script src="./static/javascripts/app.js"></script>
<script src="./static/javascripts/key.js"></script>

View File

@@ -105,7 +105,7 @@
return api('post', './logout')
},
crawl: function(url) {
return api('get', './page?url=' + url).then(json)
return api('get', './page?url=' + encodeURIComponent(url)).then(json)
}
}
})()

View File

@@ -2,6 +2,26 @@
var TITLE = document.title
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 debounce = function(callback, wait) {
var timeout
return function() {
@@ -21,6 +41,12 @@ Vue.directive('scroll', {
},
})
Vue.directive('focus', {
inserted: function(el) {
el.focus()
}
})
Vue.component('drag', {
props: ['width'],
template: '<div class="drag"></div>',
@@ -47,13 +73,13 @@ Vue.component('drag', {
})
Vue.component('dropdown', {
props: ['class', 'toggle-class', 'ref', 'drop'],
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"><slot name="button"></slot></button>
<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>
`,
@@ -176,15 +202,19 @@ Vue.component('relative-time', {
},
})
Vue.use(i18n)
var vm = new Vue({
created: function() {
this.refreshStats()
.then(this.refreshFeeds.bind(this))
.then(this.refreshItems.bind(this))
.then(this.refreshItems.bind(this, false))
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
this.updateMetaTheme(app.settings.theme_name)
this.$setLang(app.settings.language)
},
data: function() {
var s = app.settings
@@ -197,10 +227,7 @@ var vm = new Vue({
'feedNewChoice': [],
'feedNewChoiceSelected': '',
'items': [],
'itemsPage': {
'cur': 1,
'num': 1,
},
'itemsHasMore': true,
'itemSelected': null,
'itemSelectedDetails': null,
'itemSelectedReadability': '',
@@ -226,9 +253,32 @@ var vm = new Vue({
'font': s.theme_font,
'size': s.theme_size,
},
'themeColors': {
'night': '#0e0e0e',
'sepia': '#f4f0e5',
'light': '#fff',
},
'refreshRate': s.refresh_rate,
'authenticated': app.authenticated,
'feed_errors': {},
'refreshRateOptions': [
{ title: "0", value: 0 },
{ title: "10m", value: 10 },
{ title: "30m", value: 30 },
{ title: "1h", value: 60 },
{ title: "2h", value: 120 },
{ title: "4h", value: 240 },
{ title: "12h", value: 720 },
{ title: "24h", value: 1440 },
],
'language': s.language,
'languages': [
{code: 'en', name: 'English' },
{code: 'zh', name: '简体中文'},
{code: 'ru', name: 'Русский'},
]
}
},
computed: {
@@ -275,11 +325,28 @@ var vm = new Vue({
return this.itemSelectedDetails.content || ''
},
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')
},
refreshRateTitle: function () {
const entry = this.refreshRateOptions.find(o => o.value === this.refreshRate)
return entry ? entry.title : '0'
},
},
watch: {
'theme': {
deep: true,
handler: function(theme) {
this.updateMetaTheme(theme.name)
document.body.classList.value = 'theme-' + theme.name
api.settings.update({
theme_name: theme.name,
@@ -304,14 +371,18 @@ var vm = new Vue({
},
'filterSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this))
this.itemSelected = null
this.items = []
this.itemsHasMore = true
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
this.computeStats()
},
'feedSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
this.itemSelected = null
this.items = []
this.itemsHasMore = true
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
},
'itemSelected': function(newVal, oldVal) {
@@ -339,7 +410,7 @@ var vm = new Vue({
}, 500),
'itemSortNewestFirst': function(newVal, oldVal) {
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) {
if (oldVal === undefined) return // do nothing, initial setup
@@ -355,6 +426,9 @@ var vm = new Vue({
},
},
methods: {
updateMetaTheme: function(theme) {
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
},
refreshStats: function(loopMode) {
return api.status().then(function(data) {
if (loopMode && !vm.itemSelected) vm.refreshItems()
@@ -404,34 +478,53 @@ var vm = new Vue({
vm.feeds = values[1]
})
},
refreshItems: function() {
refreshItems: function(loadMore = false) {
if (this.feedSelected === null) {
vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1}
vm.itemsHasMore = false
return
}
var query = this.getItemsQuery()
if (loadMore) {
query.after = vm.items[vm.items.length-1].id
}
this.loading.items = true
return api.items.list(query).then(function(data) {
vm.items = data.list
vm.itemsPage = data.page
if (loadMore) {
vm.items = vm.items.concat(data.list)
} else {
vm.items = data.list
}
vm.itemsHasMore = data.has_more
vm.loading.items = false
// load more if there's some space left at the bottom of the item list.
vm.$nextTick(function() {
if (vm.itemsHasMore && !vm.loading.items && vm.itemListCloseToBottom()) {
vm.refreshItems(true)
}
})
})
},
itemListCloseToBottom: function() {
// approx. vertical space at the bottom of the list (loading el & paddings) when 1rem = 16px
var bottomSpace = 70
var scale = (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) / 16
var el = this.$refs.itemlist
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) {
if (this.itemsPage.cur >= this.itemsPage.num) return
if (!this.itemsHasMore) return
if (this.loading.items) return
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
if (closeToBottom) {
this.loading.moreitems = true
var query = this.getItemsQuery()
query.page = this.itemsPage.cur + 1
api.items.list(query).then(function(data) {
vm.items = vm.items.concat(data.list)
vm.itemsPage = data.page
vm.loading.items = false
})
}
if (this.itemListCloseToBottom()) return this.refreshItems(true)
if (this.itemSelected && this.itemSelected === this.items[this.items.length - 1].id) return this.refreshItems(true)
},
markItemsRead: function() {
var query = this.getItemsQuery()
@@ -439,6 +532,7 @@ var vm = new Vue({
vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1}
vm.itemSelected = null
vm.itemsHasMore = false
vm.refreshStats()
})
},
@@ -498,15 +592,20 @@ var vm = new Vue({
deleteFolder: function(folder) {
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
api.folders.delete(folder.id).then(function() {
if (vm.feedSelected === 'folder:'+folder.id) {
vm.items = []
vm.feedSelected = ''
}
vm.feedSelected = null
vm.refreshStats()
vm.refreshFeeds()
})
}
},
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) {
var newTitle = prompt('Enter new title', feed.title)
if (newTitle) {
@@ -518,12 +617,7 @@ var vm = new Vue({
deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
api.feeds.delete(feed.id).then(function() {
// unselect feed to prevent reading properties of null in template
var isSelected = !vm.feedSelected
|| (vm.feedSelected === 'feed:'+feed.id
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
if (isSelected) vm.feedSelected = null
vm.feedSelected = null
vm.refreshStats()
vm.refreshFeeds()
})
@@ -633,10 +727,7 @@ var vm = new Vue({
fetchAllFeeds: function() {
if (this.loading.feeds) return
api.feeds.refresh().then(function() {
// NOTE: this is hacky
setTimeout(function() {
vm.refreshStats()
}, 1000)
vm.refreshStats()
})
},
computeStats: function() {
@@ -667,6 +758,97 @@ var vm = new Vue({
this.filteredFolderStats = statsFolders
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
const navigationList = this.foldersWithFeeds
.filter(folder => !folder.id || !vm.mustHideFolder(folder))
.map((folder) => {
if (this.mustHideFolder(folder)) return []
const folds = folder.id ? [`folder:${folder.id}`] : []
const feeds = (folder.is_expanded || !folder.id)
? (folder.feeds || []).filter(f => !vm.mustHideFeed(f)).map(f => `feed:${f.id}`)
: []
return folds.concat(feeds)
})
.flat()
navigationList.unshift('')
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)
})
},
changeRefreshRate: function(offset) {
const curIdx = this.refreshRateOptions.findIndex(o => o.value === this.refreshRate)
if (curIdx <= 0 && offset < 0) return
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
},
mustHideFolder: function (folder) {
return this.filterSelected
&& !(this.current.folder.id == folder.id || this.current.feed.folder_id == folder.id)
&& !this.filteredFolderStats[folder.id]
&& (!this.itemSelectedDetails || (this.feedsById[this.itemSelectedDetails.feed_id] || {}).folder_id != folder.id)
},
mustHideFeed: function (feed) {
return this.filterSelected
&& !(this.current.feed.id == feed.id)
&& !this.filteredFeedStats[feed.id]
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
},
changeLanguage(lang) {
this.$setLang(lang)
this.language = lang
api.settings.update({language: lang})
}
}
})

View File

@@ -0,0 +1,372 @@
(function (exports) {
const translations = {
"unread": {
"en": "Unread",
"zh": "未读",
"ru": "Непрочитанные"
},
"starred": {
"en": "Starred",
"zh": "星标",
"ru": "Избранные"
},
"all": {
"en": "All",
"zh": "全部",
"ru": "Все"
},
"settings": {
"en": "Settings",
"zh": "设置",
"ru": "Настройки"
},
"new_feed": {
"en": "New Feed",
"zh": "新建订阅",
"ru": "Новая лента"
},
"refresh_feeds": {
"en": "Refresh Feeds",
"zh": "刷新订阅",
"ru": "Обновить ленты"
},
"theme": {
"en": "Theme",
"zh": "主题",
"ru": "Тема"
},
"auto_refresh": {
"en": "Auto Refresh",
"zh": "自动刷新",
"ru": "Автообновление"
},
"show_first": {
"en": "Show first",
"zh": "优先显示",
"ru": "Сначала"
},
"new": {
"en": "New",
"zh": "最新",
"ru": "Новые"
},
"old": {
"en": "Old",
"zh": "最旧",
"ru": "Старые"
},
"subscriptions": {
"en": "Subscriptions",
"zh": "订阅管理",
"ru": "Подписки"
},
"import": {
"en": "Import",
"zh": "导入",
"ru": "Импорт"
},
"export": {
"en": "Export",
"zh": "导出",
"ru": "Экспорт"
},
"shortcuts": {
"en": "Shortcuts",
"zh": "快捷键",
"ru": "Горячие клавиши"
},
"log_out": {
"en": "Log out",
"zh": "登出",
"ru": "Выйти"
},
"all_unread": {
"en": "All Unread",
"zh": "全部未读",
"ru": "Все непрочитанные"
},
"all_starred": {
"en": "All Starred",
"zh": "全部星标",
"ru": "Все избранные"
},
"all_feeds": {
"en": "All Feeds",
"zh": "全部订阅",
"ru": "Все ленты"
},
"refreshing": {
"en": "Refreshing",
"zh": "正在刷新",
"ru": "Обновление"
},
"left": {
"en": "left",
"zh": "剩余",
"ru": "осталось"
},
"show_feeds": {
"en": "Show Feeds",
"zh": "显示订阅",
"ru": "Показать ленты"
},
"mark_all_read": {
"en": "Mark All Read",
"zh": "全部标记为已读",
"ru": "Отметить все как прочитанные"
},
"feed_settings": {
"en": "Feed Settings",
"zh": "订阅设置",
"ru": "Настройки ленты"
},
"folder_settings": {
"en": "Folder Settings",
"zh": "文件夹设置",
"ru": "Настройки папки"
},
"website": {
"en": "Website",
"zh": "网站",
"ru": "Сайт"
},
"feed_link": {
"en": "Feed Link",
"zh": "订阅链接",
"ru": "Ссылка на ленту"
},
"rename": {
"en": "Rename",
"zh": "重命名",
"ru": "Переименовать"
},
"change_link": {
"en": "Change Link",
"zh": "修改链接",
"ru": "Изменить ссылку"
},
"move_to": {
"en": "Move to...",
"zh": "移动到...",
"ru": "Переместить в..."
},
"new_folder": {
"en": "new folder",
"zh": "新建文件夹",
"ru": "новая папка"
},
"delete": {
"en": "Delete",
"zh": "删除",
"ru": "Удалить"
},
"mark_starred": {
"en": "Mark Starred",
"zh": "标记星标",
"ru": "Пометить избранным"
},
"mark_unread": {
"en": "Mark Unread",
"zh": "标记未读",
"ru": "Пометить непрочитанным"
},
"appearance": {
"en": "Appearance",
"zh": "外观",
"ru": "Внешний вид"
},
"read_here": {
"en": "Read Here",
"zh": "在此阅读",
"ru": "Читать здесь"
},
"open_link": {
"en": "Open Link",
"zh": "打开链接",
"ru": "Открыть ссылку"
},
"previous_article": {
"en": "Previous Article",
"zh": "上一篇",
"ru": "Предыдущая статья"
},
"next_article": {
"en": "Next Article",
"zh": "下一篇",
"ru": "Следующая статья"
},
"close_article": {
"en": "Close Article",
"zh": "关闭文章",
"ru": "Закрыть статью"
},
"untitled": {
"en": "untitled",
"zh": "无标题",
"ru": "без названия"
},
"sans_serif": {
"en": "sans-serif",
"zh": "无衬线",
"ru": "sans-serif"
},
"serif": {
"en": "serif",
"zh": "衬线",
"ru": "serif"
},
"monospace": {
"en": "monospace",
"zh": "等宽",
"ru": "monospace"
},
"url": {
"en": "URL",
"zh": "网址",
"ru": "URL"
},
"folder": {
"en": "Folder",
"zh": "文件夹",
"ru": "Папка"
},
"add": {
"en": "Add",
"zh": "添加",
"ru": "Добавить"
},
"keyboard_shortcuts": {
"en": "Keyboard Shortcuts",
"zh": "键盘快捷键",
"ru": "Горячие клавиши"
},
"multiple_feeds_found": {
"en": "Multiple feeds found. Choose one below:",
"zh": "找到多个订阅源,请选择一个:",
"ru": "Найдено несколько лент. Выберите одну:"
},
"cancel": {
"en": "cancel",
"zh": "取消",
"ru": "отмена"
},
"kb_show_filters": {
"en": "show unread / starred / all feeds",
"zh": "显示未读/星标/全部订阅",
"ru": "показать непрочитанные / избранные / все ленты"
},
"kb_focus_search": {
"en": "focus the search bar",
"zh": "聚焦搜索栏",
"ru": "фокус на строку поиска"
},
"kb_next_prev_article": {
"en": "next / prev article",
"zh": "下一篇/上一篇文章",
"ru": "следующая / предыдущая статья"
},
"kb_next_prev_feed": {
"en": "next / prev feed",
"zh": "下一个/上一个订阅",
"ru": "следующая / предыдущая лента"
},
"kb_close_article": {
"en": "close article",
"zh": "关闭文章",
"ru": "закрыть статью"
},
"kb_mark_all_read": {
"en": "mark all read",
"zh": "全部标记为已读",
"ru": "отметить все как прочитанные"
},
"kb_mark_read": {
"en": "mark read / unread",
"zh": "标记已读/未读",
"ru": "отметить как прочитанное / непрочитанное"
},
"kb_mark_starred": {
"en": "mark starred / unstarred",
"zh": "标记星标/取消星标",
"ru": "пометить избранным / убрать из избранного"
},
"kb_open_link": {
"en": "open link",
"zh": "打开链接",
"ru": "открыть ссылку"
},
"kb_read_here": {
"en": "read here",
"zh": "在此阅读",
"ru": "читать здесь"
},
"kb_scroll_content": {
"en": "scroll content forward / backward",
"zh": "向前/向后滚动内容",
"ru": "прокрутка вперед / назад"
},
"prompt_folder_name": {
"en": "Enter folder name:",
"zh": "请输入文件夹名称:",
"ru": "Введите имя папки:"
},
"prompt_new_title": {
"en": "Enter new title",
"zh": "请输入新标题",
"ru": "Введите новый заголовок"
},
"prompt_feed_link": {
"en": "Enter feed link",
"zh": "请输入订阅链接",
"ru": "Введите ссылку на ленту"
},
"confirm_delete_folder": {
"en": "Are you sure you want to delete",
"zh": "确定要删除",
"ru": "Вы уверены, что хотите удалить"
},
"confirm_delete_feed": {
"en": "Are you sure you want to delete",
"zh": "确定要删除",
"ru": "Вы уверены, что хотите удалить"
},
"alert_no_feeds": {
"en": "No feeds found at the given url.",
"zh": "在指定的网址未找到订阅源。",
"ru": "Лент по данному адресу не найдено."
},
"login": {
"en": "Login",
"zh": "登录",
"ru": "Вход"
},
"username": {
"en": "Username",
"zh": "用户名",
"ru": "Имя пользователя"
},
"password": {
"en": "Password",
"zh": "密码",
"ru": "Пароль"
},
};
class i18n {
constructor() {
this.lang = 'en'
}
setLang(lang) {
this.lang = lang
}
$t(code) {
return translations[code][this.lang]
}
}
exports.i18n = {
install(Vue, opts) {
const x = new i18n();
Vue.prototype.$t = x.$t
Vue.prototype.$setLang = x.setLang
}
}
})(window)

View File

@@ -1,79 +1,4 @@
function scrollto(target, scroll) {
var padding = 10
var targetRect = target.getBoundingClientRect()
var scrollRect = scroll.getBoundingClientRect()
// target
var relativeOffset = targetRect.y - scrollRect.y
var absoluteOffset = relativeOffset + scroll.scrollTop
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
var newPos = scroll.scrollTop
if (relativeOffset < padding) {
newPos = absoluteOffset - padding
} else {
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
}
scroll.scrollTop = Math.round(newPos)
}
var helperFunctions = {
// navigation helper, navigate relative to selected item
navigateToItem: function(relativePosition) {
if (vm.itemSelected == null) {
// if no item is selected, select first
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
if (itemPosition === -1) {
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var newPosition = itemPosition + relativePosition
if (newPosition < 0 || newPosition >= vm.items.length) return
vm.itemSelected = vm.items[newPosition].id
vm.$nextTick(function() {
var scroll = document.querySelector('#item-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
})
},
// navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) {
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
.map(function(r) { return r.value })
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
if (currentFeedPosition == -1) {
vm.feedSelected = ''
return
}
var newPosition = currentFeedPosition+relativePosition
if (newPosition < 0 || newPosition >= navigationList.length) return
vm.feedSelected = navigationList[newPosition]
vm.$nextTick(function() {
var scroll = document.querySelector('#feed-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
})
},
scrollContent: function(direction) {
var padding = 40
var scroll = document.querySelector('.content')
@@ -92,7 +17,7 @@ var helperFunctions = {
var shortcutFunctions = {
openItemLink: function() {
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
window.open(vm.itemSelectedDetails.link, '_blank')
window.open(vm.itemSelectedDetails.link, '_blank', 'noopener,noreferrer')
}
},
toggleReadability: function() {
@@ -118,16 +43,16 @@ var shortcutFunctions = {
document.getElementById("searchbar").focus()
},
nextItem(){
helperFunctions.navigateToItem(+1)
vm.navigateToItem(+1)
},
previousItem() {
helperFunctions.navigateToItem(-1)
vm.navigateToItem(-1)
},
nextFeed(){
helperFunctions.navigateToFeed(+1)
vm.navigateToFeed(+1)
},
previousFeed() {
helperFunctions.navigateToFeed(-1)
vm.navigateToFeed(-1)
},
scrollForward: function() {
helperFunctions.scrollContent(+1)
@@ -135,6 +60,9 @@ var shortcutFunctions = {
scrollBackward: function() {
helperFunctions.scrollContent(-1)
},
closeItem: function () {
vm.itemSelected = null
},
showAll() {
vm.filterSelected = ''
},
@@ -160,11 +88,31 @@ var keybindings = {
"h": shortcutFunctions.previousFeed,
"f": shortcutFunctions.scrollForward,
"b": shortcutFunctions.scrollBackward,
"q": shortcutFunctions.closeItem,
"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,
"KeyQ": shortcutFunctions.closeItem,
"Digit1": shortcutFunctions.showUnread,
"Digit2": shortcutFunctions.showStarred,
"Digit3": shortcutFunctions.showAll,
}
function isTextBox(element) {
var tagName = element.tagName.toLowerCase()
// Input elements that aren't text
@@ -179,10 +127,10 @@ function isTextBox(element) {
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) {
if (isTextBox(event.target) || event.metaKey || event.ctrlKey || event.altKey) {
return
}
var keybindFunction = keybindings[event.key]
var keybindFunction = keybindings[event.key] || codebindings[event.code]
if (keybindFunction) {
event.preventDefault()
keybindFunction()

View File

@@ -5,7 +5,8 @@
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/icon.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">
<style>
form {
@@ -21,7 +22,7 @@
}
</style>
</head>
<body>
<body class="theme-{% .settings.theme_name %}">
<form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt="">
{% if .error %}
@@ -30,7 +31,7 @@
<div class="form-group">
<label for="username">Username</label>
<input name="username" class="form-control" id="username" autocomplete="off"
value="{% if .username %}{% .username %}{% end %}" required>
value="{% if .username %}{% .username %}{% end %}" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>

View File

@@ -2,8 +2,11 @@
display: none !important;
}
body {
html {
font-size: 15px !important;
}
body {
overscroll-behavior: none;
}
@@ -85,6 +88,10 @@ select.form-control:not([multiple]):not([size]) {
outline: none;
}
.table-compact {
color: unset !important;
}
.table-compact tr td:first-child {
padding-left: 0;
}
@@ -93,6 +100,10 @@ select.form-control:not([multiple]):not([size]) {
padding-right: 0;
}
.scroll-touch {
-webkit-overflow-scrolling: touch;
}
/* custom elements */
.font-serif {
@@ -160,7 +171,9 @@ select.form-control:not([multiple]):not([size]) {
opacity: 0;
position: absolute;
z-index: -1;
top: 0; left: 0;
top: 0;
left: 0;
height: 100%;
}
.selectgroup + .selectgroup {
@@ -349,6 +362,11 @@ select.form-control:not([multiple]):not([size]) {
line-height: 1.5;
}
.content-wrapper {
max-width: 60rem;
margin: 0 auto;
}
.content img, .content video {
max-width: 100%;
height: auto;
@@ -360,6 +378,27 @@ select.form-control:not([multiple]):not([size]) {
margin-bottom: 0.5rem;
}
.content .video-wrapper {
position: relative;
display: block;
width: 100%;
overflow: hidden;
}
.content .video-wrapper::before {
display: block;
padding-top: 56.25%; /* 16x9 aspect ratio */
content: "";
}
.content .video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.content pre {
overflow-x: auto;
color: inherit;
@@ -395,6 +434,11 @@ select.form-control:not([multiple]):not([size]) {
font-size: 1rem;
}
.content p {
margin-top: 1rem;
margin-bottom: 1rem;
}
/* theme: light */
button.theme-light {
@@ -402,11 +446,11 @@ button.theme-light {
}
a,
.btn-link:hover,
.toolbar-item.active {
.btn-link:hover {
color: #0080d4;
}
.toolbar-item.active,
.dropdown-item.active,
.dropdown-item:active,
.selectgroup input:checked + .selectgroup-label {

View File

@@ -2,6 +2,7 @@ package htmlutil
import (
"net/url"
"strings"
)
func Any(els []string, el string, match func(string, string) bool) bool {
@@ -31,3 +32,7 @@ func URLDomain(val string) string {
}
return val
}
func IsAPossibleLink(val string) bool {
return strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://")
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"regexp"
"strings"
"unicode"
"golang.org/x/net/html"
)
@@ -61,3 +62,16 @@ func ExtractText(content string) string {
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

@@ -24,3 +24,21 @@ func TestExtractText(t *testing.T) {
}
}
}
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

@@ -6,6 +6,7 @@ package readability
import (
"bytes"
"errors"
"fmt"
"io"
"math"
@@ -26,10 +27,16 @@ var (
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`)
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`)
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
@@ -59,6 +66,9 @@ func ExtractContent(page io.Reader) (string, error) {
best = body
break
}
if best == nil {
return "", errors.New("failed to extract content")
}
}
//log.Printf("[Readability] TopCandidate: %v", topCandidate)

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
@@ -58,19 +59,36 @@ func Sanitize(baseURL, input string) string {
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 + ">")
}
tagStack = append(tagStack, 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) {
@@ -129,7 +147,10 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
}
attrNames = append(attrNames, attribute.Key)
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
htmlAttrs = append(
htmlAttrs,
fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)),
)
}
extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
@@ -144,13 +165,27 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
func getExtraAttributes(tagName string) ([]string, []string) {
switch tagName {
case "a":
return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
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"`}
return []string{
"sandbox",
"loading",
}, []string{
`sandbox="allow-scripts allow-same-origin allow-popups"`,
`loading="lazy"`,
}
case "img":
return []string{"loading"}, []string{`loading="lazy"`}
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
default:
return nil, nil
}
@@ -191,10 +226,8 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
for element, attrs := range elements {
if tagName == element {
for _, attribute := range attributes {
for _, attr := range attrs {
if attr == attribute {
return true
}
if slices.Contains(attrs, attribute) {
return true
}
}
@@ -251,13 +284,7 @@ func isValidIframeSource(baseURL, src string) bool {
return true
}
for _, safeDomain := range whitelist {
if safeDomain == domain {
return true
}
}
return false
return slices.Contains(whitelist, domain)
}
func getTagAllowList() map[string][]string {
@@ -321,13 +348,7 @@ func getTagAllowList() map[string][]string {
}
func inList(needle string, haystack []string) bool {
for _, element := range haystack {
if element == needle {
return true
}
}
return false
return slices.Contains(haystack, needle)
}
func isBlockedTag(tagName string) bool {
@@ -337,17 +358,10 @@ func isBlockedTag(tagName string) bool {
"style",
}
for _, element := range blacklist {
if element == tagName {
return true
}
}
return false
return slices.Contains(blacklist, tagName)
}
/*
One or more strings separated by commas, indicating possible image sources for the user agent to use.
Each string is composed of:
@@ -355,7 +369,6 @@ Each string is composed of:
- 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
@@ -417,3 +430,22 @@ func isValidDataAttribute(value string) bool {
}
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

@@ -8,10 +8,11 @@ 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>`
output := Sanitize("http://example.org/", input)
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 input != output {
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
if have != want {
t.Errorf("Wrong output: \nwant: %#v\nhave: %#v", want, have)
}
}
@@ -27,31 +28,31 @@ func TestImgWithTextDataURL(t *testing.T) {
func TestImgWithDataURL(t *testing.T) {
input := `<img src="data:image/gif;base64,test" alt="Example">`
expected := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy">`
output := Sanitize("http://example.org/", input)
want := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
have := Sanitize("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
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">`
expected := `<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">`
output := Sanitize("http://example.org/", input)
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 output != expected {
t.Errorf(`Wrong output: %s`, output)
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">`
expected := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy">`
output := Sanitize("http://example.org/", input)
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 output != expected {
t.Errorf(`Wrong output: %s`, output)
if have != want {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
@@ -67,16 +68,16 @@ func TestSourceWithSrcsetAndMedia(t *testing.T) {
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">`
expected := `<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">`
output := Sanitize("http://example.org/", input)
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 output != expected {
t.Errorf(`Wrong output: %s`, output)
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/>with an image: <img src="http://example.org/" alt="Test" loading="lazy"/>.</p>`
input := `<p>This <br> is a <strong>text</strong><br/>.</p>`
output := Sanitize("http://example.org/", input)
if input != output {
@@ -95,11 +96,11 @@ func TestTable(t *testing.T) {
func TestRelativeURL(t *testing.T) {
input := `This <a href="/test.html">link is relative</a> and this image: <img src="../folder/image.png"/>`
expected := `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"/>`
output := Sanitize("http://example.org/", input)
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 expected != output {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
if want != have {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
}
}
@@ -163,6 +164,16 @@ func TestInvalidNestedTag(t *testing.T) {
}
}
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 := ``
@@ -175,7 +186,7 @@ func TestInvalidIFrame(t *testing.T) {
func TestIFrameWithChildElements(t *testing.T) {
input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
expected := `<iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></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 {
@@ -255,7 +266,7 @@ func TestEspaceAttributes(t *testing.T) {
func TestReplaceIframeURL(t *testing.T) {
input := `<iframe src="https://player.vimeo.com/video/123456?title=0&amp;byline=0"></iframe>`
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&amp;byline=0" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></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 {
@@ -292,3 +303,13 @@ func TestReplaceStyle(t *testing.T) {
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

@@ -1,6 +1,8 @@
package scraper
import (
"net/url"
"slices"
"strings"
"github.com/nkanaev/yarr/src/content/htmlutil"
@@ -21,10 +23,8 @@ func FindFeeds(body string, base string) map[string]string {
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
}
if slices.Contains(linkTypes, t) {
return true
}
}
return false
@@ -35,6 +35,18 @@ func FindFeeds(body string, base string) map[string]string {
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"
}
}
}
}

View File

@@ -22,6 +22,8 @@ func VideoIFrame(link string) string {
youtubeID := ""
if l.Host == "www.youtube.com" && l.Path == "/watch" {
youtubeID = l.Query().Get("v")
} else if l.Host == "www.youtube.com" && strings.HasPrefix(l.Path, "/shorts/") {
youtubeID = strings.TrimPrefix(l.Path, "/shorts/")
} else if l.Host == "youtu.be" {
youtubeID = strings.TrimLeft(l.Path, "/")
}

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

View File

@@ -1,106 +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() {
log.SetOutput(os.Stdout)
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
var addr, db, authfile, certfile, keyfile, basepath 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(&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
}
configPath, err := os.UserConfigDir()
if err != nil {
log.Fatal("Failed to get config dir: ", err)
}
if db == "" {
storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil {
log.Fatal("Failed to create app config dir: ", err)
}
db = filepath.Join(storagePath, "storage.db")
}
log.Printf("using db file %s", db)
var username, password string
if authfile != "" {
f, err := os.Open(authfile)
if err != nil {
log.Fatal("Failed to open auth file: ", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 2 {
log.Fatalf("Invalid auth: %v (expected `username:password`)", line)
}
username = parts[0]
password = parts[1]
break
}
}
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
log.Fatalf("Both cert & key files are required")
}
store, err := storage.New(db)
if err != nil {
log.Fatal("Failed to initialise database: ", err)
}
srv := server.NewServer(store, addr)
if basepath != "" {
srv.BasePath = "/" + strings.Trim(basepath, "/")
}
if certfile != "" && keyfile != "" {
srv.CertFile = certfile
srv.KeyFile = keyfile
}
if username != "" && password != "" {
srv.Username = username
srv.Password = password
}
log.Printf("starting server at %s", srv.GetAddr())
if open {
platform.Open(srv.GetAddr())
}
platform.Start(srv)
}

View File

@@ -3,7 +3,6 @@ package parser
import (
"encoding/xml"
"html"
"io"
"strings"
@@ -47,6 +46,8 @@ 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
}
@@ -56,7 +57,7 @@ func (a *atomText) String() string {
if a.Type == "xhtml" {
data = a.XML
}
return html.UnescapeString(strings.TrimSpace(data))
return strings.TrimSpace(data)
}
func (links atomLinks) First(rel string) string {
@@ -81,15 +82,32 @@ func ParseAtom(r io.Reader) (*Feed, error) {
SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")),
}
for _, srcitem := range srcfeed.Entries {
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""))
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(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()),
ImageURL: srcitem.firstMediaThumbnail(),
AudioURL: "",
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

View File

@@ -40,13 +40,11 @@ func TestAtom(t *testing.T) {
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>`,
ImageURL: "",
AudioURL: "",
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>`,
},
},
}
@@ -94,6 +92,44 @@ func TestAtomHTMLTitle(t *testing.T) {
}
}
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"?>
@@ -103,9 +139,15 @@ func TestAtomImageLink(t *testing.T) {
</entry>
</feed>
`))
have := feed.Items[0].ImageURL
want := `https://example.com/image.png?width=100&height=100`
if want != have {
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)
}
}
@@ -127,7 +169,68 @@ func TestAtomImageLinkDuplicated(t *testing.T) {
if want != have {
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
}
if feed.Items[0].ImageURL != "" {
t.Fatal("item.image_url must be unset if present in the content")
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()
}
}

View File

@@ -2,6 +2,7 @@ package parser
import (
"bytes"
"crypto/sha256"
"encoding/xml"
"errors"
"fmt"
@@ -9,15 +10,27 @@ import (
"net/url"
"strings"
"time"
"github.com/nkanaev/yarr/src/content/htmlutil"
"golang.org/x/net/html/charset"
)
var UnknownFormat = errors.New("unknown feed format")
var ErrUnknownFormat = errors.New("unknown feed format")
type processor func(r io.Reader) (*Feed, error)
type feedProbe struct {
feedType string
callback func(r io.Reader) (*Feed, error)
encoding string
}
func sniff(lookup string) (string, processor) {
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))
@@ -26,24 +39,42 @@ func sniff(lookup string) (string, processor) {
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":
return "rss", ParseRSS
out.feedType = "rss"
out.callback = ParseRSS
return
case "RDF":
return "rdf", ParseRDF
out.feedType = "rdf"
out.callback = ParseRDF
return
case "feed":
return "atom", ParseAtom
out.feedType = "atom"
out.callback = ParseAtom
return
}
}
}
case '{':
return "json", ParseJSON
out.feedType = "json"
out.callback = ParseJSON
return
}
return "", nil
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 {
@@ -56,18 +87,43 @@ func Parse(r io.Reader) (*Feed, error) {
r = io.MultiReader(bytes.NewReader(lookup), r)
}
_, callback := sniff(string(lookup))
if callback == nil {
return nil, UnknownFormat
out := sniff(string(lookup))
if out.feedType == "" {
return nil, ErrUnknownFormat
}
feed, err := callback(r)
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)
@@ -75,14 +131,17 @@ func (feed *Feed) cleanup() {
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(item.Title)
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
feed.Items[i].Content = strings.TrimSpace(item.Content)
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) {
feed.Items[i].ImageURL = ""
}
if item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) {
feed.Items[i].AudioURL = ""
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
}
}
}
@@ -114,3 +173,12 @@ func (feed *Feed) TranslateURLs(base string) error {
}
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)))
}
}
}

View File

@@ -7,38 +7,45 @@ import (
)
func TestSniff(t *testing.T) {
testcases := [][2]string{
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>`,
"rdf",
feedProbe{feedType: "rdf", callback: ParseRDF},
},
{
`<?xml version="1.0" encoding="ISO-8859-1"?><rss version="2.0"><channel></channel></rss>`,
"rss",
feedProbe{feedType: "rss", callback: ParseRSS, encoding: "iso-8859-1"},
},
{
`<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`,
"rss",
feedProbe{feedType: "rss", callback: ParseRSS},
},
{
`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
"atom",
feedProbe{feedType: "atom", callback: ParseAtom, encoding: "utf-8"},
},
{
`{}`,
"json",
feedProbe{feedType: "json", callback: ParseJSON},
},
{
`<!DOCTYPE html><html><head><title></title></head><body></body></html>`,
"",
feedProbe{},
},
}
for _, testcase := range testcases {
have, _ := sniff(testcase[0])
want := testcase[1]
if want != have {
t.Log(testcase[0])
t.Errorf("Invalid format: want=%#v have=%#v", want, have)
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,
)
}
}
}
@@ -107,3 +114,73 @@ func TestParseFeedWithBOM(t *testing.T) {
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)
}
}

View File

@@ -1,5 +1,9 @@
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"`
@@ -8,12 +12,17 @@ type media struct {
}
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"`
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 {
@@ -21,35 +30,68 @@ type mediaThumbnail struct {
}
type mediaDescription struct {
Type string `xml:"type,attr"`
Description 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 ""
Type string `xml:"type,attr"`
Text string `xml:",chardata"`
}
func (m *media) firstMediaDescription() string {
for _, d := range m.MediaDescriptions {
return plain2html(d.Description)
return plain2html(d.Text)
}
for _, g := range m.MediaGroups {
for _, d := range g.MediaDescriptions {
return plain2html(d.Description)
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
}

View File

@@ -14,7 +14,12 @@ type Item struct {
URL string
Title string
Content string
ImageURL string
AudioURL string
Content string
MediaLinks []MediaLink
}
type MediaLink struct {
URL string
Type string
Description string
}

View File

@@ -42,8 +42,16 @@ func TestRDFFeed(t *testing.T) {
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"},
{
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",
},
},
}

View File

@@ -20,12 +20,12 @@ type rssFeed struct {
}
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Link string `xml:"link"`
GUID rssGuid `xml:"rss guid"`
Title string `xml:"rss title"`
Link string `xml:"rss link"`
Description string `xml:"rss description"`
PubDate string `xml:"pubDate"`
Enclosures []rssEnclosure `xml:"enclosure"`
PubDate string `xml:"rss pubDate"`
Enclosures []rssEnclosure `xml:"rss enclosure"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
@@ -36,6 +36,11 @@ type rssItem struct {
media
}
type rssGuid struct {
GUID string `xml:",chardata"`
IsPermaLink string `xml:"isPermaLink,attr"`
}
type rssLink struct {
XMLName xml.Name
Data string `xml:",chardata"`
@@ -69,26 +74,40 @@ func ParseRSS(r io.Reader) (*Feed, error) {
SiteURL: srcfeed.Link,
}
for _, srcitem := range srcfeed.Items {
podcastURL := ""
mediaLinks := srcitem.mediaLinks()
for _, e := range srcitem.Enclosures {
if e.Type == "audio/mpeg" || e.Type == "audio/x-m4a" {
podcastURL = e.URL
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
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
}
}
for _, e := range srcitem.Enclosures {
if strings.HasPrefix(e.Type, "image/") {
mediaLinks = append(mediaLinks, MediaLink{URL: e.URL, Type: "image"})
}
}
permalink := ""
if srcitem.GUID.IsPermaLink == "true" {
permalink = srcitem.GUID.GUID
}
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
AudioURL: podcastURL,
ImageURL: srcitem.firstMediaThumbnail(),
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

View File

@@ -75,9 +75,15 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
</channel>
</rss>
`))
have := feed.Items[0].ImageURL
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
if have != want {
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()
@@ -127,9 +133,41 @@ func TestRSSPodcast(t *testing.T) {
</channel>
</rss>
`))
have := feed.Items[0].AudioURL
want := "http://example.com/audio.ext"
if want != have {
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()
@@ -156,7 +194,136 @@ func TestRSSPodcastDuplicated(t *testing.T) {
if want != have {
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", want, have)
}
if feed.Items[0].AudioURL != "" {
t.Fatal("item.audio_url must be unset if present in the content")
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 := range want {
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 := range want {
if !reflect.DeepEqual(want, have) {
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
}
}
}
// https://github.com/nkanaev/yarr/issues/284
func TestRSSEnclosureImage(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Post with image</title>
<link>http://example.com/post/1</link>
<enclosure url="http://example.com/photo.jpg" type="image/jpeg" length="123456"/>
</item>
</channel>
</rss>
`))
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatalf("Expected 1 media link, got %d: %#v", len(feed.Items[0].MediaLinks), feed.Items[0].MediaLinks)
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "http://example.com/photo.jpg",
Type: "image",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
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")
}
}

View File

@@ -1,6 +1,8 @@
package parser
import (
"bufio"
"bytes"
"encoding/xml"
"io"
"regexp"
@@ -30,6 +32,81 @@ func plain2html(text string) string {
func xmlDecoder(r io.Reader) *xml.Decoder {
decoder := xml.NewDecoder(r)
decoder.Strict = false
decoder.CharsetReader = charset.NewReaderLabel
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 := range want {
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
import (
"fyne.io/systray"
"github.com/nkanaev/yarr/src/server"
"github.com/nkanaev/yarr/src/systray"
)
func Start(s *server.Server) {
systrayOnReady := func() {
systray.SetIcon(Icon)
systray.SetTemplateIcon(Icon, Icon)
systray.SetTooltip("yarr")
menuOpen := systray.AddMenuItem("Open", "")
systray.AddSeparator()

View File

@@ -1,4 +1,4 @@
// +build !windows,!macos
//go:build !gui
package platform

View File

@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-anchor"
version="1.1"
id="svg905"
sodipodi:docname="icon.svg"
inkscape:version="1.0.1 (c497b03c, 2020-09-10)"
inkscape:export-filename="/Users/nkanaev/Desktop/icon.png"
inkscape:export-xdpi="2048"
inkscape:export-ydpi="2048">
<metadata
id="metadata911">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs909">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 24 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="48 : 24 : 1"
inkscape:persp3d-origin="24 : 16 : 1"
id="perspective842" />
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1440"
inkscape:window-height="900"
id="namedview907"
showgrid="false"
inkscape:zoom="4.9128436"
inkscape:cx="30.960444"
inkscape:cy="52.71331"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg905"
inkscape:document-rotation="0" />
<rect
style="fill:#212529;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
id="rect913"
width="48"
height="48"
x="0"
y="0"
ry="24"
rx="0" />
<g
id="g940"
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
style="fill:none;stroke:#ffffff;stroke-opacity:1">
<circle
cx="12"
cy="5"
id="circle899"
r="3"
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
<line
x1="12"
y1="22"
x2="12"
y2="8"
id="line901"
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
<path
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
id="path903"
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,8 +1,8 @@
// +build macos
//go:build darwin && gui
package platform
import _ "embed"
//go:embed icon.png
//go:embed icon_mac.png
var Icon []byte

BIN
src/platform/icon_mac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

118
src/platform/icon_mac.svg Normal file
View File

@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-anchor"
version="1.1"
id="svg905"
sodipodi:docname="icon_mac.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
inkscape:export-filename="icon_mac.png"
inkscape:export-xdpi="2048"
inkscape:export-ydpi="2048"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata
id="metadata911">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs909">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 24 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="48 : 24 : 1"
inkscape:persp3d-origin="24 : 16 : 1"
id="perspective842" />
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1440"
inkscape:window-height="895"
id="namedview907"
showgrid="false"
inkscape:zoom="4.9128436"
inkscape:cx="31.14286"
inkscape:cy="52.718959"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="0"
inkscape:current-layer="svg905"
inkscape:document-rotation="0"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<rect
style="display:none;fill:#800000;stroke-width:4;stroke-linecap:round;stroke-dasharray:none"
id="rect3"
width="89.561165"
height="70.427643"
x="-21.576099"
y="-7.734828"
ry="24"
inkscape:label="background-test" />
<rect
style="display:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect913"
width="48"
height="48"
x="0"
y="0"
ry="24"
rx="0"
inkscape:label="circle" />
<path
id="rect2"
style="fill:#000000;stroke:none;stroke-width:3"
inkscape:label="circle-hollow"
d="M 24 0 C 10.704 0 0 10.704 0 24 C 0 37.296 10.704 48 24 48 C 37.296 48 48 37.296 48 24 C 48 10.704 37.296 0 24 0 z M 24 7.4550781 C 27.49085 7.4550781 30.363281 10.327509 30.363281 13.818359 C 30.363281 16.611253 28.523046 19.006548 26 19.853516 L 26 36.380859 C 31.271218 35.519062 35.266025 31.300336 36.144531 26 L 34.181641 26 A 2.0000001 2.0000001 0 0 1 32.181641 24 A 2.0000001 2.0000001 0 0 1 34.181641 22 L 38.544922 22 A 2.0002001 2.0002001 0 0 1 40.544922 24 C 40.544922 33.114118 33.114111 40.544922 24 40.544922 C 14.885889 40.544922 7.4550781 33.114118 7.4550781 24 A 2.0002001 2.0002001 0 0 1 9.4550781 22 L 13.818359 22 A 2.0000001 2.0000001 0 0 1 15.818359 24 A 2.0000001 2.0000001 0 0 1 13.818359 26 L 11.855469 26 C 12.733975 31.300336 16.728783 35.519062 22 36.380859 L 22 19.853516 C 19.476954 19.006548 17.636719 16.611253 17.636719 13.818359 C 17.636719 10.327509 20.50915 7.4550781 24 7.4550781 z M 24 11.455078 C 22.670911 11.455078 21.636719 12.48927 21.636719 13.818359 C 21.636719 15.147449 22.670911 16.181641 24 16.181641 C 25.329089 16.181641 26.363281 15.147449 26.363281 13.818359 C 26.363281 12.48927 25.329089 11.455078 24 11.455078 z " />
<g
id="g1"
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
style="display:none;fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1"
inkscape:label="anchor_backup">
<circle
cx="12"
cy="5"
id="circle1"
r="3"
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
<line
x1="12"
y1="22"
x2="12"
y2="8"
id="line1"
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
id="path1"
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,4 +1,4 @@
// +build windows
//go:build windows && gui
package platform

View File

@@ -1,4 +1,4 @@
// +build !windows,!darwin
//go:build linux || freebsd || openbsd
package platform

View File

@@ -1,4 +1,4 @@
// +build darwin
//go:build darwin
package platform

View File

@@ -1,4 +1,4 @@
// +build windows
//go:build windows
package platform

View File

@@ -7,7 +7,6 @@ import (
"encoding/hex"
"net/http"
"strings"
"time"
)
func IsAuthenticated(req *http.Request, username, password string) bool {
@@ -24,10 +23,12 @@ func IsAuthenticated(req *http.Request, username, password string) bool {
func Authenticate(rw http.ResponseWriter, username, password, basepath string) {
http.SetCookie(rw, &http.Cookie{
Name: "auth",
Value: username + ":" + secret(username, password),
Expires: time.Now().Add(time.Hour * 24 * 7), // 1 week,
Path: basepath,
Name: "auth",
Value: username + ":" + secret(username, password),
MaxAge: 604800, // 1 week
Path: basepath,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}

View File

@@ -6,23 +6,23 @@ import (
"github.com/nkanaev/yarr/src/assets"
"github.com/nkanaev/yarr/src/server/router"
"github.com/nkanaev/yarr/src/storage"
)
type Middleware struct {
Username string
Password string
BasePath string
Public string
}
func unsafeMethod(method string) bool {
return method == "POST" || method == "PUT" || method == "DELETE"
Public []string
DB storage.Storage
}
func (m *Middleware) Handler(c *router.Context) {
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+m.Public) {
c.Next()
return
for _, path := range m.Public {
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+path) {
c.Next()
return
}
}
if IsAuthenticated(c.Req, m.Username, m.Password) {
c.Next()
@@ -44,12 +44,15 @@ func (m *Middleware) Handler(c *router.Context) {
c.Redirect(rootUrl)
return
} else {
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]string{
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
"username": username,
"error": "Invalid username/password",
"settings": m.DB.GetSettings().Map(),
})
return
}
}
c.HTML(http.StatusOK, assets.Template("login.html"), nil)
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
"settings": m.DB.GetSettings().Map(),
})
}

405
src/server/fever.go Normal file
View File

@@ -0,0 +1,405 @@
package server
import (
"crypto/md5"
"encoding/base64"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/nkanaev/yarr/src/server/auth"
"github.com/nkanaev/yarr/src/server/router"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
type FeverGroup struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type FeverFeedsGroup struct {
GroupID int64 `json:"group_id"`
FeedIDs string `json:"feed_ids"`
}
type FeverFeed struct {
ID int64 `json:"id"`
FaviconID int64 `json:"favicon_id"`
Title string `json:"title"`
Url string `json:"url"`
SiteUrl string `json:"site_url"`
IsSpark int `json:"is_spark"`
LastUpdated int64 `json:"last_updated_on_time"`
}
type FeverItem struct {
ID int64 `json:"id"`
FeedID int64 `json:"feed_id"`
Title string `json:"title"`
Author string `json:"author"`
HTML string `json:"html"`
Url string `json:"url"`
IsSaved int `json:"is_saved"`
IsRead int `json:"is_read"`
CreatedAt int64 `json:"created_on_time"`
}
type FeverFavicon struct {
ID int64 `json:"id"`
Data string `json:"data"`
}
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
data["api_version"] = 3
data["auth"] = 1
// TODO: remove duplicates
data["last_refreshed_on_time"] = lastRefreshed
c.JSON(http.StatusOK, data)
}
func getLastRefreshedOnTime(feedStates []model.FeedState) int64 {
var lastRefreshed int64
for _, state := range feedStates {
if state.LastRefreshed.Unix() > lastRefreshed {
lastRefreshed = state.LastRefreshed.Unix()
}
}
return lastRefreshed
}
func (s *Server) feverAuth(c *router.Context) bool {
if s.Username != "" && s.Password != "" {
apiKey := c.Req.FormValue("api_key")
apiKey = strings.ToLower(apiKey)
md5HashValue := md5.Sum(fmt.Appendf(nil, "%s:%s", s.Username, s.Password))
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
return false
}
}
return true
}
func formHasValue(values url.Values, value string) bool {
if _, ok := values[value]; ok {
return true
}
return false
}
func (s *Server) handleFever(c *router.Context) {
c.Req.ParseForm()
if !s.feverAuth(c) {
c.JSON(http.StatusOK, map[string]any{
"api_version": 3,
"auth": 0,
"last_refreshed_on_time": 0,
})
return
}
switch {
case formHasValue(c.Req.Form, "groups"):
s.feverGroupsHandler(c)
case formHasValue(c.Req.Form, "feeds"):
s.feverFeedsHandler(c)
case formHasValue(c.Req.Form, "unread_item_ids"):
s.feverUnreadItemIDsHandler(c)
case formHasValue(c.Req.Form, "saved_item_ids"):
s.feverSavedItemIDsHandler(c)
case formHasValue(c.Req.Form, "favicons"):
s.feverFaviconsHandler(c)
case formHasValue(c.Req.Form, "items"):
s.feverItemsHandler(c)
case formHasValue(c.Req.Form, "links"):
s.feverLinksHandler(c)
case formHasValue(c.Req.Form, "mark"):
s.feverMarkHandler(c)
default:
states, _ := s.db.ListFeedStates()
c.JSON(http.StatusOK, map[string]any{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": getLastRefreshedOnTime(states),
})
}
}
func joinInts(values []int64) string {
var result strings.Builder
for i, val := range values {
fmt.Fprintf(&result, "%d", val)
if i != len(values)-1 {
result.WriteString(",")
}
}
return result.String()
}
func feedGroups(db storage.Storage) []*FeverFeedsGroup {
feeds := db.ListFeeds()
groupFeeds := make(map[int64][]int64)
for _, feed := range feeds {
if feed.FolderId == nil {
continue
}
groupFeeds[*feed.FolderId] = append(groupFeeds[*feed.FolderId], feed.Id)
}
result := make([]*FeverFeedsGroup, 0)
for groupId, feedIds := range groupFeeds {
result = append(result, &FeverFeedsGroup{
GroupID: groupId,
FeedIDs: joinInts(feedIds),
})
}
return result
}
func (s *Server) feverGroupsHandler(c *router.Context) {
folders := s.db.ListFolders()
groups := make([]*FeverGroup, len(folders))
for i, folder := range folders {
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
}
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"groups": groups,
"feeds_groups": feedGroups(s.db),
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverFeedsHandler(c *router.Context) {
feeds := s.db.ListFeeds()
states, _ := s.db.ListFeedStates()
statesMap := make(map[int64]model.FeedState)
for _, state := range states {
statesMap[state.FeedID] = state
}
feverFeeds := make([]*FeverFeed, len(feeds))
for i, feed := range feeds {
var lastUpdated int64
if state, ok := statesMap[feed.Id]; ok {
lastUpdated = state.LastRefreshed.Unix()
}
feverFeeds[i] = &FeverFeed{
ID: feed.Id,
FaviconID: feed.Id,
Title: feed.Title,
Url: feed.FeedLink,
SiteUrl: feed.Link,
IsSpark: 0,
LastUpdated: lastUpdated,
}
}
writeFeverJSON(c, map[string]any{
"feeds": feverFeeds,
"feeds_groups": feedGroups(s.db),
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverFaviconsHandler(c *router.Context) {
feeds := s.db.ListFeeds()
favicons := make([]*FeverFavicon, len(feeds))
for i, feed := range feeds {
data := "data:image/gif;base64,R0lGODlhAQABAAAAACw="
if feed.HasIcon {
icon := s.db.GetFeed(feed.Id).Icon
data = fmt.Sprintf(
"data:%s;base64,%s",
http.DetectContentType(*icon),
base64.StdEncoding.EncodeToString(*icon),
)
}
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
}
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"favicons": favicons,
}, getLastRefreshedOnTime(states))
}
// for memory pressure reasons, we only return a limited number of items
// documented at https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md#items
const listLimit = 50
func (s *Server) feverItemsHandler(c *router.Context) {
filter := model.ItemFilter{}
query := c.Req.URL.Query()
switch {
case query.Get("with_ids") != "":
ids := make([]int64, 0)
for _, idstr := range strings.Split(query.Get("with_ids"), ",") {
if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil {
ids = append(ids, idnum)
}
}
filter.IDs = &ids
case query.Get("since_id") != "":
idstr := query.Get("since_id")
if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil {
filter.SinceID = &idnum
}
case query.Get("max_id") != "":
idstr := query.Get("max_id")
if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil {
filter.MaxID = &idnum
}
}
items := s.db.ListItems(filter, listLimit, true, true)
feverItems := make([]FeverItem, len(items))
for i, item := range items {
date := item.Date
time := date.Unix()
isSaved := 0
if item.Status == model.STARRED {
isSaved = 1
}
isRead := 0
if item.Status == model.READ {
isRead = 1
}
feverItems[i] = FeverItem{
ID: item.Id,
FeedID: item.FeedId,
Title: item.Title,
Author: "",
HTML: item.Content,
Url: item.Link,
IsSaved: isSaved,
IsRead: isRead,
CreatedAt: time,
}
}
totalItems := s.db.CountItems()
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"items": feverItems,
"total_items": totalItems,
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverLinksHandler(c *router.Context) {
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"links": make([]any, 0),
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
status := model.UNREAD
itemIds := make([]int64, 0)
itemFilter := model.ItemFilter{
Status: &status,
}
for {
items := s.db.ListItems(itemFilter, listLimit, true, false)
if len(items) == 0 {
break
}
for _, item := range items {
itemIds = append(itemIds, item.Id)
}
itemFilter.After = &items[len(items)-1].Id
}
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"unread_item_ids": joinInts(itemIds),
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
status := model.STARRED
itemIds := make([]int64, 0)
itemFilter := model.ItemFilter{
Status: &status,
}
for {
items := s.db.ListItems(itemFilter, listLimit, true, false)
if len(items) == 0 {
break
}
for _, item := range items {
itemIds = append(itemIds, item.Id)
}
itemFilter.After = &items[len(items)-1].Id
}
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"saved_item_ids": joinInts(itemIds),
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverMarkHandler(c *router.Context) {
id, err := strconv.ParseInt(c.Req.Form.Get("id"), 10, 64)
if err != nil {
log.Print("invalid id:", err)
return
}
switch c.Req.Form.Get("mark") {
case "item":
var status model.ItemStatus
switch c.Req.Form.Get("as") {
case "read":
status = model.READ
case "unread":
status = model.UNREAD
case "saved":
status = model.STARRED
case "unsaved":
status = model.READ
default:
c.Out.WriteHeader(http.StatusBadRequest)
return
}
s.db.UpdateItemStatus(id, status)
case "feed":
if c.Req.Form.Get("as") != "read" {
c.Out.WriteHeader(http.StatusBadRequest)
}
markFilter := model.MarkFilter{FeedID: &id}
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
if x > 0 {
before := time.Unix(x, 0).UTC()
markFilter.Before = &before
}
s.db.MarkItemsRead(markFilter)
case "group":
if c.Req.Form.Get("as") != "read" {
c.Out.WriteHeader(http.StatusBadRequest)
}
markFilter := model.MarkFilter{}
if id > 0 {
markFilter.FolderID = &id
}
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
if x > 0 {
before := time.Unix(x, 0).UTC()
markFilter.Before = &before
}
s.db.MarkItemsRead(markFilter)
default:
c.Out.WriteHeader(http.StatusBadRequest)
return
}
c.JSON(http.StatusOK, map[string]any{
"api_version": 3,
"auth": 1,
})
}

View File

@@ -1,9 +1,9 @@
package server
import "github.com/nkanaev/yarr/src/storage"
import "github.com/nkanaev/yarr/src/storage/model"
type ItemUpdateForm struct {
Status *storage.ItemStatus `json:"status,omitempty"`
Status *model.ItemStatus `json:"status,omitempty"`
}
type FolderCreateForm struct {

View File

@@ -28,15 +28,16 @@ func (rw *gzipResponseWriter) WriteHeader(statusCode int) {
}
func Middleware(c *router.Context) {
if strings.Contains(c.Req.Header.Get("Accept-Encoding"), "gzip") {
gz := &gzipResponseWriter{out: gzip.NewWriter(c.Out), src: c.Out}
defer gz.out.Close()
c.Out.Header().Set("Content-Encoding", "gzip")
c.Out = gz
if !strings.Contains(c.Req.Header.Get("Accept-Encoding"), "gzip") {
c.Next()
return
}
gz := &gzipResponseWriter{out: gzip.NewWriter(c.Out), src: c.Out}
defer gz.out.Close()
c.Out.Header().Set("Content-Encoding", "gzip")
c.Out = gz
c.Next()
}

View File

@@ -3,6 +3,8 @@ package opml
import (
"encoding/xml"
"io"
"golang.org/x/net/html/charset"
)
type opml struct {
@@ -13,6 +15,7 @@ type opml struct {
type outline struct {
Type string `xml:"type,attr,omitempty"`
Title string `xml:"text,attr"`
Title2 string `xml:"title,attr,omitempty"`
FeedUrl string `xml:"xmlUrl,attr,omitempty"`
SiteUrl string `xml:"htmlUrl,attr,omitempty"`
Outlines []outline `xml:"outline,omitempty"`
@@ -21,14 +24,18 @@ type outline struct {
func buildFolder(title string, outlines []outline) Folder {
folder := Folder{Title: title}
for _, outline := range outlines {
if outline.Type == "rss" {
if outline.Type == "rss" || outline.FeedUrl != "" {
folder.Feeds = append(folder.Feeds, Feed{
Title: outline.Title,
FeedUrl: outline.FeedUrl,
SiteUrl: outline.SiteUrl,
})
} else {
subfolder := buildFolder(outline.Title, outline.Outlines)
title := outline.Title
if title == "" {
title = outline.Title2
}
subfolder := buildFolder(title, outline.Outlines)
folder.Folders = append(folder.Folders, subfolder)
}
}
@@ -40,6 +47,7 @@ func Parse(r io.Reader) (Folder, error) {
decoder := xml.NewDecoder(r)
decoder.Entity = xml.HTMLEntity
decoder.Strict = false
decoder.CharsetReader = charset.NewReaderLabel
err := decoder.Decode(&val)
if err != nil {

View File

@@ -1,6 +1,7 @@
package opml
import (
"os"
"reflect"
"strings"
"testing"
@@ -56,3 +57,76 @@ func TestParse(t *testing.T) {
t.Fatal("invalid opml")
}
}
func TestParseFallback(t *testing.T) {
// as reported in https://github.com/nkanaev/yarr/pull/56
// the feed below comes without `outline[text]` & `outline[type=rss]` attributes
have, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<opml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1.0">
<head>
<title>Newsflow</title>
</head>
<body>
<outline title="foldertitle">
<outline htmlUrl="https://example.com" text="feedtext" title="feedtitle" xmlUrl="https://example.com/feed.xml" />
</outline>
</body>
</opml>
`))
want := Folder{
Folders: []Folder{{
Title: "foldertitle",
Feeds: []Feed{
{
Title: "feedtext",
FeedUrl: "https://example.com/feed.xml",
SiteUrl: "https://example.com",
},
},
}},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid opml")
}
}
func TestParseWithEncoding(t *testing.T) {
file, err := os.Open("sample_win1251.xml")
if err != nil {
t.Fatal(err)
}
have, err := Parse(file)
if err != nil {
t.Fatal(err)
}
want := Folder{
Title: "",
Feeds: []Feed{
{
Title: "пример1",
FeedUrl: "https://baz.com/feed.xml",
SiteUrl: "https://baz.com/",
},
},
Folders: []Folder{
{
Title: "папка",
Feeds: []Feed{
{
Title: "пример2",
FeedUrl: "https://foo.com/feed.xml",
SiteUrl: "https://foo.com/",
},
},
},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid opml")
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="windows-1251"?>
<opml version="1.1">
<head><title><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD></title></head>
<body>
<outline text="<22><><EFBFBD><EFBFBD><EFBFBD>">
<outline type="rss" text="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2" description="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2" xmlUrl="https://foo.com/feed.xml" htmlUrl="https://foo.com/"/>
</outline>
<outline type="rss" text="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1" description="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1" xmlUrl="https://baz.com/feed.xml" htmlUrl="https://baz.com/"/>
</body>
</opml>

View File

@@ -24,20 +24,20 @@ func (c *Context) Next() {
c.chain[c.index](c)
}
func (c *Context) JSON(status int, data interface{}) {
func (c *Context) JSON(status int, data any) {
body, err := json.Marshal(data)
if err != nil {
log.Fatal(err)
}
c.Out.WriteHeader(status)
c.Out.Header().Set("Content-Type", "application/json; charset=utf-8")
c.Out.WriteHeader(status)
c.Out.Write(body)
c.Out.Write([]byte("\n"))
}
func (c *Context) HTML(status int, tmpl *template.Template, data interface{}) {
c.Out.WriteHeader(status)
func (c *Context) HTML(status int, tmpl *template.Template, data any) {
c.Out.Header().Set("Content-Type", "text/html")
c.Out.WriteHeader(status)
tmpl.Execute(c.Out, data)
}

View File

@@ -32,10 +32,13 @@ func (r *Router) Use(h Handler) {
}
func (r *Router) For(path string, handler Handler) {
chain := make([]Handler, 0)
chain = append(chain, r.middle...)
chain = append(chain, handler)
x := Route{}
x.regex = routeRegexp(path)
x.chain = append(r.middle, handler)
x.chain = chain
r.routes = append(r.routes, x)
}

View File

@@ -1,15 +1,18 @@
package server
import (
"crypto/md5"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/nkanaev/yarr/src/assets"
"github.com/nkanaev/yarr/src/content/htmlutil"
"github.com/nkanaev/yarr/src/content/readability"
"github.com/nkanaev/yarr/src/content/sanitizer"
"github.com/nkanaev/yarr/src/content/silo"
@@ -17,7 +20,7 @@ import (
"github.com/nkanaev/yarr/src/server/gzip"
"github.com/nkanaev/yarr/src/server/opml"
"github.com/nkanaev/yarr/src/server/router"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
"github.com/nkanaev/yarr/src/worker"
)
@@ -31,12 +34,14 @@ func (s *Server) handler() http.Handler {
BasePath: s.BasePath,
Username: s.Username,
Password: s.Password,
Public: "/static",
Public: []string{"/static", "/fever", "/manifest.json"},
DB: s.db,
}
r.Use(a.Handler)
}
r.For("/", s.handleIndex)
r.For("/manifest.json", s.handleManifest)
r.For("/static/*path", s.handleStatic)
r.For("/api/status", s.handleStatus)
r.For("/api/folders", s.handleFolderList)
@@ -53,13 +58,14 @@ func (s *Server) handler() http.Handler {
r.For("/opml/export", s.handleOPMLExport)
r.For("/page", s.handlePageCrawl)
r.For("/logout", s.handleLogout)
r.For("/fever/", s.handleFever)
return r
}
func (s *Server) handleIndex(c *router.Context) {
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]interface{}{
"settings": s.db.GetSettings(),
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
"settings": s.db.GetSettings().Map(),
"authenticated": s.Username != "" && s.Password != "",
})
}
@@ -71,11 +77,30 @@ func (s *Server) handleStatic(c *router.Context) {
c.Out.WriteHeader(http.StatusNotFound)
return
}
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).ServeHTTP(c.Out, c.Req)
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).
ServeHTTP(c.Out, c.Req)
}
func (s *Server) handleManifest(c *router.Context) {
c.JSON(http.StatusOK, map[string]any{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"name": "yarr!",
"short_name": "yarr",
"description": "yet another rss reader",
"display": "standalone",
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
"icons": []map[string]any{
{
"src": s.BasePath + "/static/graphicarts/favicon.png",
"sizes": "64x64",
"type": "image/png",
},
},
})
}
func (s *Server) handleStatus(c *router.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"running": s.worker.FeedsPending(),
"stats": s.db.FeedStats(),
})
@@ -116,12 +141,10 @@ func (s *Server) handleFolder(c *router.Context) {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
if body.Title != nil {
s.db.RenameFolder(id, *body.Title)
}
if body.IsExpanded != nil {
s.db.ToggleFolderExpanded(id, *body.IsExpanded)
}
s.db.UpdateFolder(id, model.UpdateFolderParams{
Title: body.Title,
IsExpanded: body.IsExpanded,
})
c.Out.WriteHeader(http.StatusOK)
} else if c.Req.Method == "DELETE" {
s.db.DeleteFolder(id)
@@ -139,23 +162,67 @@ func (s *Server) handleFeedRefresh(c *router.Context) {
}
func (s *Server) handleFeedErrors(c *router.Context) {
errors := s.db.GetFeedErrors()
errors := make(map[int64]string)
states, err := s.db.ListFeedStates()
if err == nil {
for _, state := range states {
if state.LastError != "" {
errors[state.FeedID] = state.LastError
}
}
}
c.JSON(http.StatusOK, errors)
}
type feedicon struct {
ctype string
bytes []byte
etag string
}
func (s *Server) handleFeedIcon(c *router.Context) {
id, err := c.VarInt64("id")
if err != nil {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
feed := s.db.GetFeed(id)
if feed != nil && feed.Icon != nil {
c.Out.Header().Set("Content-Type", http.DetectContentType(*feed.Icon))
c.Out.Write(*feed.Icon)
} else {
c.Out.WriteHeader(http.StatusNotFound)
cachekey := "icon:" + strconv.FormatInt(id, 10)
s.cache_mutex.Lock()
cachedat := s.cache[cachekey]
s.cache_mutex.Unlock()
if cachedat == nil {
feed := s.db.GetFeed(id)
if feed == nil || feed.Icon == nil {
c.Out.WriteHeader(http.StatusNotFound)
return
}
hash := md5.New()
hash.Write(*feed.Icon)
etag := fmt.Sprintf("%x", hash.Sum(nil))[:16]
cachedat = feedicon{
ctype: http.DetectContentType(*feed.Icon),
bytes: *(*feed).Icon,
etag: etag,
}
s.cache_mutex.Lock()
s.cache[cachekey] = cachedat
s.cache_mutex.Unlock()
}
icon := cachedat.(feedicon)
if c.Req.Header.Get("If-None-Match") == icon.etag {
c.Out.WriteHeader(http.StatusNotModified)
return
}
c.Out.Header().Set("Content-Type", icon.ctype)
c.Out.Header().Set("Etag", icon.etag)
c.Out.Write(icon.bytes)
}
func (s *Server) handleFeedList(c *router.Context) {
@@ -176,21 +243,26 @@ func (s *Server) handleFeedList(c *router.Context) {
log.Printf("Faild to discover feed for %s: %s", form.Url, err)
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
case len(result.Sources) > 0:
c.JSON(http.StatusOK, map[string]interface{}{"status": "multiple", "choice": result.Sources})
case result.Feed != nil:
feed := s.db.CreateFeed(
result.Feed.Title,
"",
result.Feed.SiteURL,
result.FeedLink,
form.FolderID,
c.JSON(
http.StatusOK,
map[string]any{"status": "multiple", "choice": result.Sources},
)
s.db.CreateItems(worker.ConvertItems(result.Feed.Items, *feed))
case result.Feed != nil:
feed := s.db.CreateFeed(model.CreateFeedParams{
Title: result.Feed.Title,
Link: result.Feed.SiteURL,
FeedLink: result.FeedLink,
FolderID: form.FolderID,
})
items := worker.ConvertItems(result.Feed.Items, *feed)
if len(items) > 0 {
s.db.CreateItems(items)
}
s.worker.FindFeedFavicon(*feed)
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"status": "success",
"feed": feed,
"feed": feed,
})
default:
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
@@ -210,25 +282,34 @@ func (s *Server) handleFeed(c *router.Context) {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
body := make(map[string]interface{})
body := make(map[string]any)
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
log.Print(err)
c.Out.WriteHeader(http.StatusBadRequest)
return
}
params := model.UpdateFeedParams{}
if title, ok := body["title"]; ok {
if reflect.TypeOf(title).Kind() == reflect.String {
s.db.RenameFeed(id, title.(string))
t := title.(string)
params.Title = &t
}
}
if f_id, ok := body["folder_id"]; ok {
if f_id == nil {
s.db.UpdateFeedFolder(id, nil)
params.FolderID = model.SetNullable[int64](nil)
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
folderId := int64(f_id.(float64))
s.db.UpdateFeedFolder(id, &folderId)
params.FolderID = model.SetNullable(&folderId)
}
}
if link, ok := body["feed_link"]; ok {
if reflect.TypeOf(link).Kind() == reflect.String {
l := link.(string)
params.FeedLink = &l
}
}
s.db.UpdateFeed(id, params)
c.Out.WriteHeader(http.StatusOK)
} else if c.Req.Method == "DELETE" {
s.db.DeleteFeed(id)
@@ -250,7 +331,18 @@ func (s *Server) handleItem(c *router.Context) {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
// runtime fix for relative links
if !htmlutil.IsAPossibleLink(item.Link) {
if feed := s.db.GetFeed(item.FeedId); feed != nil {
item.Link = htmlutil.AbsoluteUrl(item.Link, feed.Link)
}
}
item.Content = sanitizer.Sanitize(item.Link, item.Content)
for i, link := range item.MediaLinks {
item.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)
}
c.JSON(http.StatusOK, item)
} else if c.Req.Method == "PUT" {
@@ -272,37 +364,46 @@ func (s *Server) handleItem(c *router.Context) {
func (s *Server) handleItemList(c *router.Context) {
if c.Req.Method == "GET" {
perPage := 20
curPage := 1
query := c.Req.URL.Query()
if page, err := c.QueryInt64("page"); err == nil {
curPage = int(page)
}
filter := storage.ItemFilter{}
filter := model.ItemFilter{}
if folderID, err := c.QueryInt64("folder_id"); err == nil {
filter.FolderID = &folderID
}
if feedID, err := c.QueryInt64("feed_id"); err == nil {
filter.FeedID = &feedID
}
if after, err := c.QueryInt64("after"); err == nil {
filter.After = &after
}
if status := query.Get("status"); len(status) != 0 {
statusValue := storage.StatusValues[status]
statusValue := model.StatusValues[status]
filter.Status = &statusValue
}
if search := query.Get("search"); len(search) != 0 {
filter.Search = &search
}
newestFirst := query.Get("oldest_first") != "true"
items := s.db.ListItems(filter, (curPage-1)*perPage, perPage, newestFirst)
count := s.db.CountItems(filter)
c.JSON(http.StatusOK, map[string]interface{}{
"page": map[string]int{
"cur": curPage,
"num": int(math.Ceil(float64(count) / float64(perPage))),
},
"list": items,
items := s.db.ListItems(filter, perPage+1, newestFirst, true)
hasMore := false
if len(items) == perPage+1 {
hasMore = true
items = items[:perPage]
}
for i, item := range items {
if item.Title == "" {
text := htmlutil.ExtractText(item.Content)
items[i].Title = htmlutil.TruncateText(text, 140)
}
}
c.JSON(http.StatusOK, map[string]any{
"list": items,
"has_more": hasMore,
})
} else if c.Req.Method == "PUT" {
filter := storage.MarkFilter{}
filter := model.MarkFilter{}
if folderID, err := c.QueryInt64("folder_id"); err == nil {
filter.FolderID = &folderID
@@ -321,14 +422,14 @@ func (s *Server) handleSettings(c *router.Context) {
if c.Req.Method == "GET" {
c.JSON(http.StatusOK, s.db.GetSettings())
} else if c.Req.Method == "PUT" {
settings := make(map[string]interface{})
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
var params model.UpdateSettingsParams
if err := json.NewDecoder(c.Req.Body).Decode(&params); err != nil {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
if s.db.UpdateSettings(settings) {
if _, ok := settings["refresh_rate"]; ok {
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate"))
if s.db.UpdateSettings(params) {
if params.RefreshRate != nil {
s.worker.SetRefreshRate(s.db.GetSettings().RefreshRate)
}
c.Out.WriteHeader(http.StatusOK)
} else {
@@ -351,16 +452,24 @@ func (s *Server) handleOPMLImport(c *router.Context) {
return
}
for _, f := range doc.Feeds {
s.db.CreateFeed(f.Title, "", f.SiteUrl, f.FeedUrl, nil)
s.db.CreateFeed(model.CreateFeedParams{
Title: f.Title,
Link: f.SiteUrl,
FeedLink: f.FeedUrl,
})
}
for _, f := range doc.Folders {
folder := s.db.CreateFolder(f.Title)
for _, ff := range f.AllFeeds() {
s.db.CreateFeed(ff.Title, "", ff.SiteUrl, ff.FeedUrl, &folder.Id)
s.db.CreateFeed(model.CreateFeedParams{
Title: ff.Title,
Link: ff.SiteUrl,
FeedLink: ff.FeedUrl,
FolderID: &folder.Id,
})
}
}
s.worker.FindFavicons()
s.worker.RefreshFeeds()
c.Out.WriteHeader(http.StatusOK)
@@ -376,9 +485,8 @@ func (s *Server) handleOPMLExport(c *router.Context) {
doc := opml.Folder{}
feedsByFolderID := make(map[int64][]*storage.Feed)
feedsByFolderID := make(map[int64][]*model.Feed)
for _, feed := range s.db.ListFeeds() {
feed := feed
if feed.FolderId == nil {
doc.Feeds = append(doc.Feeds, opml.Feed{
Title: feed.Title,
@@ -414,24 +522,31 @@ func (s *Server) handleOPMLExport(c *router.Context) {
func (s *Server) handlePageCrawl(c *router.Context) {
url := c.Req.URL.Query().Get("url")
if newUrl := silo.RedirectURL(url); newUrl != "" {
url = newUrl
}
if content := silo.VideoIFrame(url); content != "" {
c.JSON(http.StatusOK, map[string]string{
"content": content,
"content": sanitizer.Sanitize(url, content),
})
return
}
if isInternalFromURL(url) {
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
return
}
res, err := http.Get(url)
body, err := worker.GetBody(url)
if err != nil {
log.Print(err)
c.Out.WriteHeader(http.StatusBadRequest)
return
}
defer res.Body.Close()
content, err := readability.ExtractContent(res.Body)
content, err := readability.ExtractContent(strings.NewReader(body))
if err != nil {
log.Print(err)
c.Out.WriteHeader(http.StatusNoContent)
c.JSON(http.StatusOK, map[string]string{
"content": "error: " + err.Error(),
})
return
}
content = sanitizer.Sanitize(url, content)

View File

@@ -1,8 +1,17 @@
package server
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"reflect"
"testing"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
func TestStatic(t *testing.T) {
@@ -43,3 +52,64 @@ func TestStaticBanTemplates(t *testing.T) {
t.FailNow()
}
}
func TestIndexGzipped(t *testing.T) {
log.SetOutput(io.Discard)
db, _ := storage.New(":memory:")
log.SetOutput(os.Stderr)
handler := NewServer(db, "127.0.0.1:8000").handler()
url := "/"
recorder := httptest.NewRecorder()
request := httptest.NewRequest("GET", url, nil)
request.Header.Set("accept-encoding", "gzip")
handler.ServeHTTP(recorder, request)
response := recorder.Result()
if response.StatusCode != 200 {
t.FailNow()
}
if response.Header.Get("content-encoding") != "gzip" {
t.Errorf("invalid content-encoding header: %#v", response.Header.Get("content-encoding"))
}
if response.Header.Get("content-type") != "text/html" {
t.Errorf("invalid content-type header: %#v", response.Header.Get("content-type"))
}
}
func TestFeedIcons(t *testing.T) {
log.SetOutput(io.Discard)
db, _ := storage.New(":memory:")
icon := []byte("test")
feed := db.CreateFeed(model.CreateFeedParams{})
db.UpdateFeed(feed.Id, model.UpdateFeedParams{Icon: model.SetNullable(&icon)})
log.SetOutput(os.Stderr)
recorder := httptest.NewRecorder()
url := fmt.Sprintf("/api/feeds/%d/icon", feed.Id)
request := httptest.NewRequest("GET", url, nil)
handler := NewServer(db, "127.0.0.1:8000").handler()
handler.ServeHTTP(recorder, request)
response := recorder.Result()
if response.StatusCode != http.StatusOK {
t.Fatal()
}
body, _ := io.ReadAll(response.Body)
if !reflect.DeepEqual(body, icon) {
t.Fatal()
}
if response.Header.Get("Etag") == "" {
t.Fatal()
}
recorder2 := httptest.NewRecorder()
request2 := httptest.NewRequest("GET", url, nil)
request2.Header.Set("If-None-Match", response.Header.Get("Etag"))
handler.ServeHTTP(recorder2, request2)
response2 := recorder2.Result()
if response2.StatusCode != http.StatusNotModified {
t.Fatal("got", response2.StatusCode)
}
}

View File

@@ -2,16 +2,22 @@ package server
import (
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/worker"
)
type Server struct {
Addr string
db *storage.Storage
worker *worker.Worker
Addr string
db storage.Storage
worker *worker.Worker
cache map[string]any
cache_mutex *sync.Mutex
BasePath string
@@ -23,11 +29,13 @@ type Server struct {
KeyFile string
}
func NewServer(db *storage.Storage, addr string) *Server {
func NewServer(db storage.Storage, addr string) *Server {
return &Server{
db: db,
Addr: addr,
worker: worker.NewWorker(db),
db: db,
Addr: addr,
worker: worker.NewWorker(db),
cache: make(map[string]any),
cache_mutex: &sync.Mutex{},
}
}
@@ -40,22 +48,38 @@ func (h *Server) GetAddr() string {
}
func (s *Server) Start() {
refreshRate := s.db.GetSettingsValueInt64("refresh_rate")
s.worker.FindFavicons()
refreshRate := s.db.GetSettings().RefreshRate
s.worker.StartFeedCleaner()
s.worker.SetRefreshRate(refreshRate)
if refreshRate > 0 {
s.worker.RefreshFeeds()
}
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()}
var ln net.Listener
var err error
if s.CertFile != "" && s.KeyFile != "" {
err = httpserver.ListenAndServeTLS(s.CertFile, s.KeyFile)
if path, isUnix := strings.CutPrefix(s.Addr, "unix:"); isUnix {
err = os.Remove(path)
if err != nil {
log.Print(err)
}
ln, err = net.Listen("unix", path)
} else {
err = httpserver.ListenAndServe()
ln, err = net.Listen("tcp", s.Addr)
}
if err != nil {
log.Fatal(err)
}
httpserver := &http.Server{Handler: s.handler()}
if s.CertFile != "" && s.KeyFile != "" {
err = httpserver.ServeTLS(ln, s.CertFile, s.KeyFile)
ln.Close()
} else {
err = httpserver.Serve(ln)
}
if err != http.ErrServerClosed {
log.Fatal(err)
}

35
src/server/util.go Normal file
View File

@@ -0,0 +1,35 @@
package server
import (
"net"
"net/url"
"strings"
)
func isInternalFromURL(urlStr string) bool {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return false
}
host := parsedURL.Host
// Handle "host:port" format
if strings.Contains(host, ":") {
host, _, err = net.SplitHostPort(host)
if err != nil {
return false
}
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
}

31
src/server/util_test.go Normal file
View File

@@ -0,0 +1,31 @@
package server
import "testing"
func TestIsInternalFromURL(t *testing.T) {
tests := []struct {
url string
expected bool
}{
{"http://192.168.1.1:8080", true},
{"http://10.0.0.5", true},
{"http://172.16.0.1", true},
{"http://172.31.255.255", true},
{"http://172.32.0.1", false}, // outside private range
{"http://127.0.0.1", true},
{"http://127.0.0.1:7000", true},
{"http://127.0.0.1:7000/secret", true},
{"http://169.254.0.5", true},
{"http://localhost", true}, // resolves to 127.0.0.1
{"http://8.8.8.8", false},
{"http://google.com", false}, // resolves to public IPs
{"invalid-url", false}, // invalid format
{"", false}, // empty string
}
for _, test := range tests {
result := isInternalFromURL(test.url)
if result != test.expected {
t.Errorf("isInternalFromURL(%q) = %v; want %v", test.url, result, test.expected)
}
}
}

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