187 Commits

Author SHA1 Message Date
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
239 changed files with 480470 additions and 824 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

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

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

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

4
.gitignore vendored
View File

@@ -1,5 +1,9 @@
/_output /_output
/out
/yarr /yarr
*.db *.db
*.db-shm
*.db-wal
*.syso *.syso
versioninfo.rc 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)
}

View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -12,6 +13,7 @@ import (
"github.com/nkanaev/yarr/src/platform" "github.com/nkanaev/yarr/src/platform"
"github.com/nkanaev/yarr/src/server" "github.com/nkanaev/yarr/src/server"
"github.com/nkanaev/yarr/src/storage" "github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/worker"
) )
var Version string = "0.0" var Version string = "0.0"
@@ -28,10 +30,25 @@ func opt(envVar, defaultValue string) string {
return defaultValue return defaultValue
} }
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
scanner := bufio.NewScanner(authfile)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("wrong syntax (expected `username:password`)")
}
username = parts[0]
password = parts[1]
break
}
return username, password, nil
}
func main() { func main() {
platform.FixConsoleIfNeeded() platform.FixConsoleIfNeeded()
var addr, db, authfile, certfile, keyfile, basepath, logfile string var addr, db, authfile, auth, certfile, keyfile, basepath, logfile string
var ver, open bool var ver, open bool
flag.CommandLine.SetOutput(os.Stdout) flag.CommandLine.SetOutput(os.Stdout)
@@ -46,7 +63,8 @@ func main() {
flag.StringVar(&addr, "addr", opt("YARR_ADDR", "127.0.0.1:7070"), "address to run server on") 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(&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") 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(&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(&keyfile, "key-file", opt("YARR_KEYFILE", ""), "`path` to key file for https")
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`") flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
@@ -72,12 +90,16 @@ func main() {
log.SetOutput(os.Stdout) log.SetOutput(os.Stdout)
} }
configPath, err := os.UserConfigDir() if open && strings.HasPrefix(addr, "unix:") {
if err != nil { log.Fatal("Cannot open ", addr, " in browser")
log.Fatal("Failed to get config dir: ", err)
} }
if db == "" { if db == "" {
configPath, err := os.UserConfigDir()
if err != nil {
log.Fatal("Failed to get config dir: ", err)
}
storagePath := filepath.Join(configPath, "yarr") storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil { if err := os.MkdirAll(storagePath, 0755); err != nil {
log.Fatal("Failed to create app config dir: ", err) log.Fatal("Failed to create app config dir: ", err)
@@ -88,22 +110,21 @@ func main() {
log.Printf("using db file %s", db) log.Printf("using db file %s", db)
var username, password string var username, password string
var err error
if authfile != "" { if authfile != "" {
f, err := os.Open(authfile) f, err := os.Open(authfile)
if err != nil { if err != nil {
log.Fatal("Failed to open auth file: ", err) log.Fatal("Failed to open auth file: ", err)
} }
defer f.Close() defer f.Close()
scanner := bufio.NewScanner(f) username, password, err = parseAuthfile(f)
for scanner.Scan() { if err != nil {
line := scanner.Text() log.Fatal("Failed to parse auth file: ", err)
parts := strings.Split(line, ":") }
if len(parts) != 2 { } else if auth != "" {
log.Fatalf("Invalid auth: %v (expected `username:password`)", line) username, password, err = parseAuthfile(strings.NewReader(auth))
} if err != nil {
username = parts[0] log.Fatal("Failed to parse auth literal: ", err)
password = parts[1]
break
} }
} }
@@ -116,6 +137,7 @@ func main() {
log.Fatal("Failed to initialise database: ", err) log.Fatal("Failed to initialise database: ", err)
} }
worker.SetVersion(Version)
srv := server.NewServer(store, addr) srv := server.NewServer(store, addr)
if basepath != "" { if basepath != "" {

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"

125
doc/changelog.md Normal file
View File

@@ -0,0 +1,125 @@
# upcoming
- (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,74 +0,0 @@
# upcoming
- (fix) handling encodings (thanks to @f100024 & @fserb)
- (fix) parsing xml feeds with illegal characters (thanks to @stepelu for the report)
- (fix) old articles reappearing as unread (thanks to @adaszko for the report)
- (fix) item list scrolling issue on large screens (thanks to @bielej for the report)
- (fix) keyboard shortcuts color in dark mode (thanks to @John09f9 for the report)
- (etc) autofocus when adding a new feed (thanks to @lakuapik)
# v2.2 (2021-11-20)
- (fix) windows console support (thanks to @dufferzafar for the report)
- (fix) remove html tags from article titles (thanks to Alex Went for the report)
- (etc) autoselect current folder when adding a new feed (thanks to @krkk)
- (etc) folder/feed settings menu available across all filters
# v2.1 (2021-08-16)
- (new) configuration via env variables
- (fix) missing `content-type` headers (thanks to @verahawk for the report)
- (fix) handle opml files not following the spec (thanks to @huangnauh for the report)
- (fix) pagination in unread/starred feeds (thanks to @Farow for the report)
- (fix) handling feeds with non-utf8 encodings (thanks to @fserb for the report)
- (fix) errors caused by empty feeds (thanks to @decke)
- (fix) recognize all audio mime types as podcasts (thanks to @krkk)
- (fix) ui tweaks (thanks to @Farow)
# v2.0 (2021-04-18)
- (new) user interface tweaks
- (new) feed parser fully rewritten
- (new) show youtube/vimeo iframes in "read here"
- (new) keyboard shortcuts for article scrolling & toggling "read here"
- (new) more options for auto-refresh intervals
- (fix) `-base` not serving static files (thanks to @vfaronov)
- (etc) 3rd-party dependencies reduced to the bare minimum
special thanks to @tillcash for feedback & suggestions.
# v1.4 (2021-03-11)
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
- (new) show podcast audio
- (fix) deleting feeds
- (etc) minor ui tweaks & changes
# v1.3 (2021-02-18)
- (fix) log out functionality if authentication is set
- (fix) import opml if authentication is set
- (fix) login page if authentication is set (thanks to @einschmidt)
# v1.2 (2021-02-11)
- (new) autorefresh rate
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
- (new) show feed errors in feed management modal
- (new) `-open` flag for automatically opening the server url
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
- (new) `-auth-file` flag for authentication
- (new) `-cert-file` & `-key-file` flags for TLS
- (fix) wrapping long words in the ui to prevent vertical scroll
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
# v1.1 (2020-10-05)
- (new) responsive design
- (fix) server crash on favicon fetch timeout (reported by @minioin)
- (fix) handling byte order marks in feeds (reported by @ilaer)
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
# v1.0 (2020-09-24)
Initial Release

19
doc/fever.md Normal file
View File

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

68
doc/samples.yml Normal file
View File

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

0
doc/todo.txt Normal file
View File

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: 223 KiB

After

Width:  |  Height:  |  Size: 173 KiB

89
etc/windows_versioninfo.sh Executable file
View File

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

12
go.mod
View File

@@ -1,9 +1,13 @@
module github.com/nkanaev/yarr module github.com/nkanaev/yarr
go 1.16 go 1.23.0
toolchain go1.23.5
require ( require (
github.com/mattn/go-sqlite3 v1.14.7 github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 golang.org/x/net v0.38.0
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 golang.org/x/sys v0.31.0
) )
require golang.org/x/text v0.23.0 // indirect

20
go.sum
View File

@@ -1,12 +1,8 @@
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 h1:cdsMqa2nXzqlgs183pHxtvoVwU7CyzaCTAUOg94af4c= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -1,33 +1,90 @@
VERSION=2.3 VERSION=2.5
GITHASH=$(shell git rev-parse --short=8 HEAD) GITHASH=$(shell git rev-parse --short=8 HEAD)
CGO_ENABLED=1 GO_TAGS = sqlite_foreign_keys sqlite_json
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
GO_LDFLAGS = -s -w GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)' GO_FLAGS_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: export CGO_ENABLED=1
mkdir -p _output
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
build_macos: default: test host
mkdir -p _output/macos
GOOS=darwin GOARCH=amd64 go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr src/main.go
cp src/platform/icon.png _output/macos/icon.png
go run bin/package_macos.go -outdir _output/macos -version "$(VERSION)"
build_linux: # platform-specific files
mkdir -p _output/linux
GOOS=linux GOARCH=amd64 go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr src/main.go
build_windows: etc/icon.icns: etc/icon_macos.png
mkdir -p _output/windows mkdir -p etc/icon.iconset
go run bin/generate_versioninfo.go -version "$(VERSION)" -outfile src/platform/versioninfo.rc 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 windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
GOOS=windows GOARCH=amd64 go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe src/main.go
# build targets
host:
go build $(GO_FLAGS) -o out/yarr ./cmd/yarr
darwin_amd64:
# cross-compilation not supported: CC="zig cc -target x86_64-macos-none"
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
darwin_arm64:
# cross-compilation not supported: CC="zig cc -target aarch64-macos-none"
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
linux_amd64:
CC="zig cc -target x86_64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=amd64 \
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
linux_arm64:
CC="zig cc -target aarch64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm64 \
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
linux_armv7:
CC="zig cc -target arm-linux-musleabihf -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm GOARM=7 \
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
windows_amd64:
CC="zig cc -target x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
windows_arm64:
CC="zig cc -target aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
darwin_arm64_gui: etc/icon.icns
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
darwin_amd64_gui: etc/icon.icns
GOOS=darwin GOARCH=amd64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
windows_amd64_gui: src/platform/versioninfo.rc
GOOS=windows GOARCH=amd64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
windows_arm64_gui: src/platform/versioninfo.rc
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
serve: serve:
go run -tags "sqlite_foreign_keys" src/main.go -db local.db go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db local.db
test: 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 test

View File

@@ -3,7 +3,7 @@
**yarr** (yet another rss reader) is a web-based feed aggregator which can be used both **yarr** (yet another rss reader) is a web-based feed aggregator which can be used both
as a desktop application and a personal self-hosted server. as a desktop application and a personal self-hosted server.
It is written in Go with the frontend in Vue.js. The storage is backed by SQLite. The app is a single binary with an embedded database (SQLite).
![screenshot](etc/promo.png) ![screenshot](etc/promo.png)
@@ -11,59 +11,28 @@ It is written in Go with the frontend in Vue.js. The storage is backed by SQLite
The latest prebuilt binaries for Linux/MacOS/Windows are available The latest prebuilt binaries for Linux/MacOS/Windows are available
[here](https://github.com/nkanaev/yarr/releases/latest). [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:
To open the app follow the instructions provided [here][macos-open] or run the command below:
xattr -d com.apple.quarantine /Applications/yarr.app * MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
* Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
* Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac [macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
### windows
Download `yarr-*-windows32.zip`, unzip it, open `yarr.exe`
### linux
The Linux version doesn't come with the desktop environment integration.
For easy access on DE it is recommended to create a desktop menu entry by
by following the steps below:
unzip -x yarr*.zip
sudo mv yarr /usr/local/bin/yarr
sudo nano /usr/local/share/applications/yarr.desktop
and pasting the content:
[Desktop Entry]
Name=yarr
Exec=/usr/local/bin/yarr -open
Icon=rss
Type=Application
Categories=Internet;
For self-hosting, see `yarr -h` for auth, tls & server configuration flags. For self-hosting, see `yarr -h` for auth, tls & server configuration flags.
## build See more:
Install `Go >= 1.16` and `gcc`. Get the source code: * [Building from source code](doc/build.md)
* [Fever API support](doc/fever.md)
git clone https://github.com/nkanaev/yarr.git
Then run one of the corresponding commands:
# create an executable for the host os
make build_macos # -> _output/macos/yarr.app
make build_linux # -> _output/linux/yarr
make build_windows # -> _output/windows/yarr.exe
# ... or start a dev server locally
make serve # starts a server at http://localhost:7070
# ... or build a docker image
docker build -t yarr .
## credits ## credits

View File

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

View File

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

After

Width:  |  Height:  |  Size: 269 B

View File

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

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

@@ -5,7 +5,10 @@
<title>yarr!</title> <title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css"> <link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css"> <link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/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"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<script> <script>
window.app = window.app || {} window.app = window.app || {}
@@ -21,20 +24,23 @@
<div class="p-2 toolbar d-flex align-items-center"> <div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div> <div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<button class="toolbar-item" <button class="toolbar-item ml-1"
:class="{active: filterSelected == 'unread'}" :class="{active: filterSelected == 'unread'}"
:aria-pressed="filterSelected == 'unread'"
title="Unread" title="Unread"
@click="filterSelected = 'unread'"> @click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span> <span class="icon">{% inline "circle-full.svg" %}</span>
</button> </button>
<button class="toolbar-item" <button class="toolbar-item mx-1"
:class="{active: filterSelected == 'starred'}" :class="{active: filterSelected == 'starred'}"
:aria-pressed="filterSelected == 'starred'"
title="Starred" title="Starred"
@click="filterSelected = 'starred'"> @click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span> <span class="icon">{% inline "star-full.svg" %}</span>
</button> </button>
<button class="toolbar-item" <button class="toolbar-item mr-1"
:class="{active: filterSelected == ''}" :class="{active: filterSelected == ''}"
:aria-pressed="filterSelected == ''"
title="All" title="All"
@click="filterSelected = ''"> @click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span> <span class="icon">{% inline "assorted.svg" %}</span>
@@ -57,10 +63,12 @@
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<header class="dropdown-header">Theme</header> <header class="dropdown-header" role="heading" aria-level="2">Theme</header>
<div class="row text-center m-0"> <div class="row text-center m-0">
<button class="btn btn-link col-4 px-0 rounded-0" <button class="btn btn-link col-4 px-0 rounded-0"
:class="'theme-'+t" :class="'theme-'+t"
:aria-label="t"
:aria-pressed="theme.name == t"
@click.stop="theme.name = t" @click.stop="theme.name = t"
v-for="t in ['light', 'sepia', 'night']"> v-for="t in ['light', 'sepia', 'night']">
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span> <span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
@@ -69,25 +77,33 @@
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<header class="dropdown-header">Auto Refresh</header> <header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
<div class="row text-center m-0"> <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"
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button> @click.stop="changeRefreshRate(-1)"
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button> :disabled="!refreshRate">
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button> <span class="icon">
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button> {% inline "chevron-down.svg" %}
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button> </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>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<header class="dropdown-header">Show first</header> <header class="dropdown-header" role="heading" aria-level="2">Show first</header>
<div class="d-flex text-center"> <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" :aria-pressed="itemSortNewestFirst" :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=false">Old</button>
</div> </div>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<header class="dropdown-header">Subscriptions</header> <header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1"> <form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
<input type="file" <input type="file"
id="opml-import" id="opml-import"
@@ -115,7 +131,7 @@
</button> </button>
</dropdown> </dropdown>
</div> </div>
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1"> <div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
<label class="selectgroup"> <label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected"> <input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100"> <div class="selectgroup-label d-flex align-items-center w-100">
@@ -128,11 +144,9 @@
</label> </label>
<div v-for="folder in foldersWithFeeds"> <div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1" <label class="selectgroup mt-1"
:class="{'d-none': filterSelected :class="{'d-none': mustHideFolder(folder)}"
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id) v-if="folder.id">
&& !filteredFolderStats[folder.id] <input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
&& (!itemSelectedDetails || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id"> <div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2" <span class="icon mr-2"
:class="{expanded: folder.is_expanded}" :class="{expanded: folder.is_expanded}"
@@ -145,10 +159,7 @@
</label> </label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}"> <div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup" <label class="selectgroup"
:class="{'d-none': filterSelected :class="{'d-none': mustHideFeed(feed)}"
&& !(current.feed.id == feed.id)
&& !filteredFeedStats[feed.id]
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
v-for="feed in folder.feeds"> v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected"> <input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100"> <div class="selectgroup-label d-flex align-items-center w-100">
@@ -204,12 +215,12 @@
<template v-slot:button> <template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span> <span class="icon">{% inline "more-horizontal.svg" %}</span>
</template> </template>
<header class="dropdown-header">{{ current.feed.title }}</header> <header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link"> <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> <span class="icon mr-1">{% inline "globe.svg" %}</span>
Website Website
</a> </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> <span class="icon mr-1">{% inline "rss.svg" %}</span>
Feed Link Feed Link
</a> </a>
@@ -218,8 +229,12 @@
<span class="icon mr-1">{% inline "edit.svg" %}</span> <span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename Rename
</button> </button>
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Change Link
</button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<header class="dropdown-header">Move to...</header> <header class="dropdown-header" role="heading" aria-level="2">Move to...</header>
<button class="dropdown-item" <button class="dropdown-item"
v-if="folder.id != current.feed.folder_id" v-if="folder.id != current.feed.folder_id"
v-for="folder in folders" v-for="folder in folders"
@@ -249,7 +264,7 @@
<template v-slot:button> <template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span> <span class="icon">{% inline "more-horizontal.svg" %}</span>
</template> </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)"> <button class="dropdown-item" @click="renameFolder(current.folder)">
<span class="icon mr-1">{% inline "edit.svg" %}</span> <span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename Rename
@@ -261,7 +276,7 @@
</button> </button>
</dropdown> </dropdown>
</div> </div>
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist"> <div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id" <label v-for="item in items" :key="item.id"
class="selectgroup"> class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected"> <input type="radio" name="item" :value="item.id" v-model="itemSelected">
@@ -272,7 +287,7 @@
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span> <span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition> </transition>
<small class="flex-fill text-truncate mr-1"> <small class="flex-fill text-truncate mr-1">
{{ feedsById[item.feed_id].title }} {{ (feedsById[item.feed_id] || {}).title }}
</small> </small>
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small> <small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
</div> </div>
@@ -320,30 +335,48 @@
title="Read Here"> title="Read Here">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span> <span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button> </button>
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" title="Open Link"> <a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="Open Link">
<span class="icon">{% inline "external-link.svg" %}</span> <span class="icon">{% inline "external-link.svg" %}</span>
</a> </a>
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
<span class="icon">{% inline "chevron-right.svg" %}</span>
</button>
<button class="toolbar-item" @click="itemSelected=null" title="Close Article"> <button class="toolbar-item" @click="itemSelected=null" title="Close Article">
<span class="icon">{% inline "x.svg" %}</span> <span class="icon">{% inline "x.svg" %}</span>
</button> </button>
</div> </div>
<div v-if="itemSelectedDetails" <div v-if="itemSelectedDetails"
ref="content" ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto" class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}" :class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
:style="{'font-size': theme.size + 'rem'}"> :style="{'font-size': theme.size + 'rem'}">
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1> <div class="content-wrapper">
<div class="text-muted"> <h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div> <div class="text-muted">
<time>{{ formatDate(itemSelectedDetails.date) }}</time> <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> </div>
<hr>
<div v-if="!itemSelectedReadability">
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
</div>
<div v-html="itemSelectedContent"></div>
</div> </div>
</div> </div>
<modal :open="!!settings" @hide="settings = ''"> <modal :open="!!settings" @hide="settings = ''">
@@ -390,6 +423,7 @@
<tr><td colspan=2>&nbsp;</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>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>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
<tr><td><kbd>q</kbd></td> <td>close article</td></tr>
<tr><td colspan=2>&nbsp;</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 all read</td></tr>

View File

@@ -105,7 +105,7 @@
return api('post', './logout') return api('post', './logout')
}, },
crawl: function(url) { 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 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 debounce = function(callback, wait) {
var timeout var timeout
return function() { return function() {
@@ -191,6 +211,7 @@ var vm = new Vue({
api.feeds.list_errors().then(function(errors) { api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors vm.feed_errors = errors
}) })
this.updateMetaTheme(app.settings.theme_name)
}, },
data: function() { data: function() {
var s = app.settings var s = app.settings
@@ -229,9 +250,25 @@ var vm = new Vue({
'font': s.theme_font, 'font': s.theme_font,
'size': s.theme_size, 'size': s.theme_size,
}, },
'themeColors': {
'night': '#0e0e0e',
'sepia': '#f4f0e5',
'light': '#fff',
},
'refreshRate': s.refresh_rate, 'refreshRate': s.refresh_rate,
'authenticated': app.authenticated, 'authenticated': app.authenticated,
'feed_errors': {}, '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 },
],
} }
}, },
computed: { computed: {
@@ -278,11 +315,28 @@ var vm = new Vue({
return this.itemSelectedDetails.content || '' 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: { watch: {
'theme': { 'theme': {
deep: true, deep: true,
handler: function(theme) { handler: function(theme) {
this.updateMetaTheme(theme.name)
document.body.classList.value = 'theme-' + theme.name document.body.classList.value = 'theme-' + theme.name
api.settings.update({ api.settings.update({
theme_name: theme.name, theme_name: theme.name,
@@ -358,6 +412,9 @@ var vm = new Vue({
}, },
}, },
methods: { methods: {
updateMetaTheme: function(theme) {
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
},
refreshStats: function(loopMode) { refreshStats: function(loopMode) {
return api.status().then(function(data) { return api.status().then(function(data) {
if (loopMode && !vm.itemSelected) vm.refreshItems() if (loopMode && !vm.itemSelected) vm.refreshItems()
@@ -407,9 +464,10 @@ var vm = new Vue({
vm.feeds = values[1] vm.feeds = values[1]
}) })
}, },
refreshItems: function(loadMore) { refreshItems: function(loadMore = false) {
if (this.feedSelected === null) { if (this.feedSelected === null) {
vm.items = [] vm.items = []
vm.itemsHasMore = false
return return
} }
@@ -419,7 +477,7 @@ var vm = new Vue({
} }
this.loading.items = true this.loading.items = true
api.items.list(query).then(function(data) { return api.items.list(query).then(function(data) {
if (loadMore) { if (loadMore) {
vm.items = vm.items.concat(data.list) vm.items = vm.items.concat(data.list)
} else { } else {
@@ -437,14 +495,22 @@ var vm = new Vue({
}) })
}, },
itemListCloseToBottom: function() { itemListCloseToBottom: function() {
// approx. vertical space at the bottom of the list (loading el & paddings) when 1rem = 16px
var bottomSpace = 70
var scale = (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) / 16
var el = this.$refs.itemlist var el = this.$refs.itemlist
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
if (el.scrollHeight === 0) return false // element is invisible (responsive design)
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < bottomSpace * scale
return closeToBottom return closeToBottom
}, },
loadMoreItems: function(event, el) { loadMoreItems: function(event, el) {
if (!this.itemsHasMore) return if (!this.itemsHasMore) return
if (this.loading.items) return if (this.loading.items) return
if (this.itemListCloseToBottom()) this.refreshItems(true) 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() { markItemsRead: function() {
var query = this.getItemsQuery() var query = this.getItemsQuery()
@@ -512,15 +578,20 @@ var vm = new Vue({
deleteFolder: function(folder) { deleteFolder: function(folder) {
if (confirm('Are you sure you want to delete ' + folder.title + '?')) { if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
api.folders.delete(folder.id).then(function() { api.folders.delete(folder.id).then(function() {
if (vm.feedSelected === 'folder:'+folder.id) { vm.feedSelected = null
vm.items = []
vm.feedSelected = ''
}
vm.refreshStats() vm.refreshStats()
vm.refreshFeeds() vm.refreshFeeds()
}) })
} }
}, },
updateFeedLink: function(feed) {
var newLink = prompt('Enter feed link', feed.feed_link)
if (newLink) {
api.feeds.update(feed.id, {feed_link: newLink}).then(function() {
feed.feed_link = newLink
})
}
},
renameFeed: function(feed) { renameFeed: function(feed) {
var newTitle = prompt('Enter new title', feed.title) var newTitle = prompt('Enter new title', feed.title)
if (newTitle) { if (newTitle) {
@@ -532,12 +603,7 @@ var vm = new Vue({
deleteFeed: function(feed) { deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) { if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
api.feeds.delete(feed.id).then(function() { api.feeds.delete(feed.id).then(function() {
// unselect feed to prevent reading properties of null in template vm.feedSelected = null
var isSelected = !vm.feedSelected
|| (vm.feedSelected === 'feed:'+feed.id
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
if (isSelected) vm.feedSelected = null
vm.refreshStats() vm.refreshStats()
vm.refreshFeeds() vm.refreshFeeds()
}) })
@@ -678,6 +744,90 @@ var vm = new Vue({
this.filteredFolderStats = statsFolders this.filteredFolderStats = statsFolders
this.filteredTotalStats = statsTotal this.filteredTotalStats = statsTotal
}, },
// navigation helper, navigate relative to selected item
navigateToItem: function(relativePosition) {
let vm = this
if (vm.itemSelected == null) {
// if no item is selected, select first
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
if (itemPosition === -1) {
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var newPosition = itemPosition + relativePosition
if (newPosition < 0 || newPosition >= vm.items.length) return
vm.itemSelected = vm.items[newPosition].id
vm.$nextTick(function() {
var scroll = document.querySelector('#item-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
vm.loadMoreItems()
})
},
// navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) {
let vm = this
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)
},
} }
}) })

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 = { 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) { scrollContent: function(direction) {
var padding = 40 var padding = 40
var scroll = document.querySelector('.content') var scroll = document.querySelector('.content')
@@ -92,7 +17,7 @@ var helperFunctions = {
var shortcutFunctions = { var shortcutFunctions = {
openItemLink: function() { openItemLink: function() {
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) { if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
window.open(vm.itemSelectedDetails.link, '_blank') window.open(vm.itemSelectedDetails.link, '_blank', 'noopener,noreferrer')
} }
}, },
toggleReadability: function() { toggleReadability: function() {
@@ -118,16 +43,16 @@ var shortcutFunctions = {
document.getElementById("searchbar").focus() document.getElementById("searchbar").focus()
}, },
nextItem(){ nextItem(){
helperFunctions.navigateToItem(+1) vm.navigateToItem(+1)
}, },
previousItem() { previousItem() {
helperFunctions.navigateToItem(-1) vm.navigateToItem(-1)
}, },
nextFeed(){ nextFeed(){
helperFunctions.navigateToFeed(+1) vm.navigateToFeed(+1)
}, },
previousFeed() { previousFeed() {
helperFunctions.navigateToFeed(-1) vm.navigateToFeed(-1)
}, },
scrollForward: function() { scrollForward: function() {
helperFunctions.scrollContent(+1) helperFunctions.scrollContent(+1)
@@ -135,6 +60,9 @@ var shortcutFunctions = {
scrollBackward: function() { scrollBackward: function() {
helperFunctions.scrollContent(-1) helperFunctions.scrollContent(-1)
}, },
closeItem: function () {
vm.itemSelected = null
},
showAll() { showAll() {
vm.filterSelected = '' vm.filterSelected = ''
}, },
@@ -160,11 +88,31 @@ var keybindings = {
"h": shortcutFunctions.previousFeed, "h": shortcutFunctions.previousFeed,
"f": shortcutFunctions.scrollForward, "f": shortcutFunctions.scrollForward,
"b": shortcutFunctions.scrollBackward, "b": shortcutFunctions.scrollBackward,
"q": shortcutFunctions.closeItem,
"1": shortcutFunctions.showUnread, "1": shortcutFunctions.showUnread,
"2": shortcutFunctions.showStarred, "2": shortcutFunctions.showStarred,
"3": shortcutFunctions.showAll, "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) { function isTextBox(element) {
var tagName = element.tagName.toLowerCase() var tagName = element.tagName.toLowerCase()
// Input elements that aren't text // Input elements that aren't text
@@ -179,10 +127,10 @@ function isTextBox(element) {
document.addEventListener('keydown',function(event) { document.addEventListener('keydown',function(event) {
// Ignore while focused on text or // Ignore while focused on text or
// when using modifier keys (to not clash with browser behaviour) // 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 return
} }
var keybindFunction = keybindings[event.key] var keybindFunction = keybindings[event.key] || codebindings[event.code]
if (keybindFunction) { if (keybindFunction) {
event.preventDefault() event.preventDefault()
keybindFunction() keybindFunction()

View File

@@ -5,7 +5,8 @@
<title>yarr!</title> <title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css"> <link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css"> <link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/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"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style> <style>
form { form {
@@ -21,7 +22,7 @@
} }
</style> </style>
</head> </head>
<body> <body class="theme-{% .settings.theme_name %}">
<form action="" method="post"> <form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt=""> <img src="./static/graphicarts/anchor.svg" alt="">
{% if .error %} {% if .error %}

View File

@@ -2,8 +2,11 @@
display: none !important; display: none !important;
} }
body { html {
font-size: 15px !important; font-size: 15px !important;
}
body {
overscroll-behavior: none; overscroll-behavior: none;
} }
@@ -97,6 +100,10 @@ select.form-control:not([multiple]):not([size]) {
padding-right: 0; padding-right: 0;
} }
.scroll-touch {
-webkit-overflow-scrolling: touch;
}
/* custom elements */ /* custom elements */
.font-serif { .font-serif {
@@ -164,7 +171,9 @@ select.form-control:not([multiple]):not([size]) {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
z-index: -1; z-index: -1;
top: 0; left: 0; top: 0;
left: 0;
height: 100%;
} }
.selectgroup + .selectgroup { .selectgroup + .selectgroup {
@@ -353,6 +362,11 @@ select.form-control:not([multiple]):not([size]) {
line-height: 1.5; line-height: 1.5;
} }
.content-wrapper {
max-width: 60rem;
margin: 0 auto;
}
.content img, .content video { .content img, .content video {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
@@ -420,6 +434,11 @@ select.form-control:not([multiple]):not([size]) {
font-size: 1rem; font-size: 1rem;
} }
.content p {
margin-top: 1rem;
margin-bottom: 1rem;
}
/* theme: light */ /* theme: light */
button.theme-light { button.theme-light {
@@ -427,11 +446,11 @@ button.theme-light {
} }
a, a,
.btn-link:hover, .btn-link:hover {
.toolbar-item.active {
color: #0080d4; color: #0080d4;
} }
.toolbar-item.active,
.dropdown-item.active, .dropdown-item.active,
.dropdown-item:active, .dropdown-item:active,
.selectgroup input:checked + .selectgroup-label { .selectgroup input:checked + .selectgroup-label {

View File

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

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"regexp" "regexp"
"strings" "strings"
"unicode"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
@@ -61,3 +62,16 @@ func ExtractText(content string) string {
text = whitespaceRegex.ReplaceAllLiteralString(text, " ") text = whitespaceRegex.ReplaceAllLiteralString(text, " ")
return 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 ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"math" "math"
@@ -59,6 +60,9 @@ func ExtractContent(page io.Reader) (string, error) {
best = body best = body
break break
} }
if best == nil {
return "", errors.New("failed to extract content")
}
} }
//log.Printf("[Readability] TopCandidate: %v", topCandidate) //log.Printf("[Readability] TopCandidate: %v", topCandidate)

View File

@@ -167,7 +167,7 @@ func getExtraAttributes(tagName string) ([]string, []string) {
case "iframe": 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": case "img":
return []string{"loading"}, []string{`loading="lazy"`} return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
default: default:
return nil, nil return nil, nil
} }
@@ -364,7 +364,6 @@ func isBlockedTag(tagName string) bool {
} }
/* /*
One or more strings separated by commas, indicating possible image sources for the user agent to use. One or more strings separated by commas, indicating possible image sources for the user agent to use.
Each string is composed of: Each string is composed of:
@@ -372,7 +371,6 @@ Each string is composed of:
- Optionally, whitespace followed by one 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 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). - A pixel density descriptor (a positive floating point number directly followed by x).
*/ */
func sanitizeSrcsetAttr(baseURL, value string) string { func sanitizeSrcsetAttr(baseURL, value string) string {
var sanitizedSources []string var sanitizedSources []string

View File

@@ -8,10 +8,11 @@ import "testing"
func TestValidInput(t *testing.T) { 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>` 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 { if have != want {
t.Errorf(`Wrong output: "%s" != "%s"`, input, output) t.Errorf("Wrong output: \nwant: %#v\nhave: %#v", want, have)
} }
} }
@@ -27,31 +28,31 @@ func TestImgWithTextDataURL(t *testing.T) {
func TestImgWithDataURL(t *testing.T) { func TestImgWithDataURL(t *testing.T) {
input := `<img src="data:image/gif;base64,test" alt="Example">` input := `<img src="data:image/gif;base64,test" alt="Example">`
expected := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy">` want := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
output := Sanitize("http://example.org/", input) have := Sanitize("http://example.org/", input)
if output != expected { if have != want {
t.Errorf(`Wrong output: %s`, output) t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
} }
} }
func TestImgWithSrcset(t *testing.T) { 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">` 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">` 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">`
output := Sanitize("http://example.org/", input) have := Sanitize("http://example.org/", input)
if output != expected { if have != want {
t.Errorf(`Wrong output: %s`, output) t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
} }
} }
func TestImgWithSrcsetAndDataURL(t *testing.T) { func TestImgWithSrcsetAndDataURL(t *testing.T) {
input := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example">` 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">` want := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
output := Sanitize("http://example.org/", input) have := Sanitize("http://example.org/", input)
if output != expected { if have != want {
t.Errorf(`Wrong output: %s`, output) t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
} }
} }
@@ -67,16 +68,16 @@ func TestSourceWithSrcsetAndMedia(t *testing.T) {
func TestMediumImgWithSrcset(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">` 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">` 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">`
output := Sanitize("http://example.org/", input) have := Sanitize("http://example.org/", input)
if output != expected { if have != want {
t.Errorf(`Wrong output: %s`, output) t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
} }
} }
func TestSelfClosingTags(t *testing.T) { 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) output := Sanitize("http://example.org/", input)
if input != output { if input != output {
@@ -95,11 +96,11 @@ func TestTable(t *testing.T) {
func TestRelativeURL(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"/>` 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"/>` 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"/>`
output := Sanitize("http://example.org/", input) have := Sanitize("http://example.org/", input)
if expected != output { if want != have {
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output) t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
} }
} }
@@ -165,11 +166,11 @@ func TestInvalidNestedTag(t *testing.T) {
func TestValidIFrame(t *testing.T) { func TestValidIFrame(t *testing.T) {
input := `<iframe src="http://example.org/"></iframe>` input := `<iframe src="http://example.org/"></iframe>`
expected := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>` want := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
output := Sanitize("http://example.org/", input) have := Sanitize("http://example.org/", input)
if expected != output { if want != have {
t.Errorf("Wrong output:\nwant: %s\nhave: %s", expected, output) t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
} }
} }

View File

@@ -1,6 +1,7 @@
package scraper package scraper
import ( import (
"net/url"
"strings" "strings"
"github.com/nkanaev/yarr/src/content/htmlutil" "github.com/nkanaev/yarr/src/content/htmlutil"
@@ -35,6 +36,18 @@ func FindFeeds(body string, base string) map[string]string {
link := htmlutil.AbsoluteUrl(href, base) link := htmlutil.AbsoluteUrl(href, base)
if link != "" { if link != "" {
candidates[link] = name 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 := "" youtubeID := ""
if l.Host == "www.youtube.com" && l.Path == "/watch" { if l.Host == "www.youtube.com" && l.Path == "/watch" {
youtubeID = l.Query().Get("v") 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" { } else if l.Host == "youtu.be" {
youtubeID = strings.TrimLeft(l.Path, "/") 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

@@ -3,7 +3,6 @@ package parser
import ( import (
"encoding/xml" "encoding/xml"
"html"
"io" "io"
"strings" "strings"
@@ -47,6 +46,8 @@ type atomLinks []atomLink
func (a *atomText) Text() string { func (a *atomText) Text() string {
if a.Type == "html" { if a.Type == "html" {
return htmlutil.ExtractText(a.Data) return htmlutil.ExtractText(a.Data)
} else if a.Type == "xhtml" {
return htmlutil.ExtractText(a.XML)
} }
return a.Data return a.Data
} }
@@ -56,7 +57,7 @@ func (a *atomText) String() string {
if a.Type == "xhtml" { if a.Type == "xhtml" {
data = a.XML data = a.XML
} }
return html.UnescapeString(strings.TrimSpace(data)) return strings.TrimSpace(data)
} }
func (links atomLinks) First(rel string) string { func (links atomLinks) First(rel string) string {
@@ -81,15 +82,23 @@ func ParseAtom(r io.Reader) (*Feed, error) {
SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")), SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")),
} }
for _, srcitem := range srcfeed.Entries { 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{ dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.ID, link), GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)), Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link, URL: link,
Title: srcitem.Title.Text(), Title: srcitem.Title.Text(),
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()), Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
ImageURL: srcitem.firstMediaThumbnail(), MediaLinks: mediaLinks,
AudioURL: "",
}) })
} }
return dstfeed, nil return dstfeed, nil

View File

@@ -40,13 +40,11 @@ func TestAtom(t *testing.T) {
SiteURL: "http://example.org/", SiteURL: "http://example.org/",
Items: []Item{ Items: []Item{
{ {
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a", GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
Date: time.Unix(1071340202, 0).UTC(), Date: time.Unix(1071340202, 0).UTC(),
URL: "http://example.org/2003/12/13/atom03.html", URL: "http://example.org/2003/12/13/atom03.html",
Title: "Atom-Powered Robots Run Amok", Title: "Atom-Powered Robots Run Amok",
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`, Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
ImageURL: "",
AudioURL: "",
}, },
}, },
} }
@@ -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) { func TestAtomImageLink(t *testing.T) {
feed, _ := Parse(strings.NewReader(` feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@@ -103,9 +139,15 @@ func TestAtomImageLink(t *testing.T) {
</entry> </entry>
</feed> </feed>
`)) `))
have := feed.Items[0].ImageURL if len(feed.Items[0].MediaLinks) != 1 {
want := `https://example.com/image.png?width=100&height=100` t.Fatalf("Expected 1 media link, got: %#v", feed.Items[0].MediaLinks)
if want != have { }
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) 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 { if want != have {
t.Fatalf("want: %#v\nhave: %#v\n", want, have) t.Fatalf("want: %#v\nhave: %#v\n", want, have)
} }
if feed.Items[0].ImageURL != "" { if len(feed.Items[0].MediaLinks) != 0 {
t.Fatal("item.image_url must be unset if present in the content") 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 ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/xml" "encoding/xml"
"errors" "errors"
"fmt" "fmt"
@@ -119,6 +120,7 @@ func ParseAndFix(r io.Reader, baseURL, fallbackEncoding string) (*Feed, error) {
} }
feed.TranslateURLs(baseURL) feed.TranslateURLs(baseURL)
feed.SetMissingDatesTo(time.Now()) feed.SetMissingDatesTo(time.Now())
feed.SetMissingGUIDs()
return feed, nil return feed, nil
} }
@@ -132,11 +134,14 @@ func (feed *Feed) cleanup() {
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title)) feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
feed.Items[i].Content = strings.TrimSpace(item.Content) feed.Items[i].Content = strings.TrimSpace(item.Content)
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) { if len(feed.Items[i].MediaLinks) > 0 {
feed.Items[i].ImageURL = "" mediaLinks := make([]MediaLink, 0)
} for _, link := range item.MediaLinks {
if item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) { if !strings.Contains(item.Content, link.URL) {
feed.Items[i].AudioURL = "" mediaLinks = append(mediaLinks, link)
}
}
feed.Items[i].MediaLinks = mediaLinks
} }
} }
} }
@@ -168,3 +173,12 @@ func (feed *Feed) TranslateURLs(base string) error {
} }
return nil 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,9 +7,9 @@ import (
) )
func TestSniff(t *testing.T) { func TestSniff(t *testing.T) {
testcases := []struct{ testcases := []struct {
input string input string
want feedProbe want feedProbe
}{ }{
{ {
`<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF>`, `<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF>`,
@@ -150,3 +150,32 @@ func TestParseCleanIllegalCharsInNonUTF8(t *testing.T) {
t.Fatalf("invalid feed, got: %v", feed) 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 package parser
import (
"strings"
)
type media struct { type media struct {
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"` MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"` MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
@@ -8,12 +12,17 @@ type media struct {
} }
type mediaGroup struct { type mediaGroup struct {
MediaContent []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"` MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"` MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
} }
type mediaContent struct { 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 { type mediaThumbnail struct {
@@ -21,8 +30,8 @@ type mediaThumbnail struct {
} }
type mediaDescription struct { type mediaDescription struct {
Type string `xml:"type,attr"` Type string `xml:"type,attr"`
Description string `xml:",chardata"` Text string `xml:",chardata"`
} }
func (m *media) firstMediaThumbnail() string { func (m *media) firstMediaThumbnail() string {
@@ -44,12 +53,59 @@ func (m *media) firstMediaThumbnail() string {
func (m *media) firstMediaDescription() string { func (m *media) firstMediaDescription() string {
for _, d := range m.MediaDescriptions { for _, d := range m.MediaDescriptions {
return plain2html(d.Description) return plain2html(d.Text)
} }
for _, g := range m.MediaGroups { for _, g := range m.MediaGroups {
for _, d := range g.MediaDescriptions { for _, d := range g.MediaDescriptions {
return plain2html(d.Description) return plain2html(d.Text)
} }
} }
return "" 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 URL string
Title string Title string
Content string Content string
ImageURL string MediaLinks []MediaLink
AudioURL string }
type MediaLink struct {
URL string
Type string
Description string
} }

View File

@@ -20,7 +20,7 @@ type rssFeed struct {
} }
type rssItem struct { type rssItem struct {
GUID string `xml:"guid"` GUID rssGuid `xml:"guid"`
Title string `xml:"title"` Title string `xml:"title"`
Link string `xml:"rss link"` Link string `xml:"rss link"`
Description string `xml:"rss description"` Description string `xml:"rss description"`
@@ -36,6 +36,11 @@ type rssItem struct {
media media
} }
type rssGuid struct {
GUID string `xml:",chardata"`
IsPermaLink string `xml:"isPermaLink,attr"`
}
type rssLink struct { type rssLink struct {
XMLName xml.Name XMLName xml.Name
Data string `xml:",chardata"` Data string `xml:",chardata"`
@@ -69,26 +74,30 @@ func ParseRSS(r io.Reader) (*Feed, error) {
SiteURL: srcfeed.Link, SiteURL: srcfeed.Link,
} }
for _, srcitem := range srcfeed.Items { for _, srcitem := range srcfeed.Items {
podcastURL := "" mediaLinks := srcitem.mediaLinks()
for _, e := range srcitem.Enclosures { for _, e := range srcitem.Enclosures {
if strings.HasPrefix(e.Type, "audio/") { if strings.HasPrefix(e.Type, "audio/") {
podcastURL = e.URL podcastURL := e.URL
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) { if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
podcastURL = srcitem.OrigEnclosureLink podcastURL = srcitem.OrigEnclosureLink
} }
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
break break
} }
} }
permalink := ""
if srcitem.GUID.IsPermaLink == "true" {
permalink = srcitem.GUID.GUID
}
dstfeed.Items = append(dstfeed.Items, Item{ dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.GUID, srcitem.Link), GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)), Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link), URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title, Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description), Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
AudioURL: podcastURL, MediaLinks: mediaLinks,
ImageURL: srcitem.firstMediaThumbnail(),
}) })
} }
return dstfeed, nil return dstfeed, nil

View File

@@ -75,9 +75,15 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
</channel> </channel>
</rss> </rss>
`)) `))
have := feed.Items[0].ImageURL if len(feed.Items[0].MediaLinks) != 1 {
want := "https://i.vimeocdn.com/video/1092705247_960.jpg" t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
if have != want { }
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("want: %#v", want)
t.Logf("have: %#v", have) t.Logf("have: %#v", have)
t.FailNow() t.FailNow()
@@ -127,9 +133,15 @@ func TestRSSPodcast(t *testing.T) {
</channel> </channel>
</rss> </rss>
`)) `))
have := feed.Items[0].AudioURL if len(feed.Items[0].MediaLinks) != 1 {
want := "http://example.com/audio.ext" t.Fatal("Invalid media links")
if want != have { }
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("want: %#v", want)
t.Logf("have: %#v", have) t.Logf("have: %#v", have)
t.FailNow() t.FailNow()
@@ -147,9 +159,15 @@ func TestRSSOpusPodcast(t *testing.T) {
</channel> </channel>
</rss> </rss>
`)) `))
have := feed.Items[0].AudioURL if len(feed.Items[0].MediaLinks) != 1 {
want := "http://example.com/audio.ext" t.Fatal("Invalid media links")
if want != have { }
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("want: %#v", want)
t.Logf("have: %#v", have) t.Logf("have: %#v", have)
t.FailNow() t.FailNow()
@@ -176,8 +194,9 @@ func TestRSSPodcastDuplicated(t *testing.T) {
if want != have { if want != have {
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", 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")
} }
} }
@@ -203,3 +222,67 @@ func TestRSSTitleHTMLTags(t *testing.T) {
} }
} }
} }
func TestRSSIsPermalink(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<guid isPermaLink="true">http://example.com/posts/1</guid>
</item>
</channel>
</rss>
`))
have := feed.Items
want := []Item{
{
GUID: "http://example.com/posts/1",
URL: "http://example.com/posts/1",
},
}
for i := 0; i < len(want); i++ {
if !reflect.DeepEqual(want, have) {
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
}
}
}
func TestRSSMultipleMedia(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<item>
<guid isPermaLink="true">http://example.com/posts/1</guid>
<media:content url="https://example.com/path/to/image1.png" type="image/png" fileSize="1000" medium="image">
<media:description type="plain">description 1</media:description>
</media:content>
<media:content url="https://example.com/path/to/image2.png" type="image/png" fileSize="2000" medium="image">
<media:description type="plain">description 2</media:description>
</media:content>
<media:content url="https://example.com/path/to/video1.mp4" type="video/mp4" fileSize="2000" medium="image">
<media:description type="plain">video description</media:description>
</media:content>
</item>
</channel>
</rss>
`))
have := feed.Items
want := []Item{
{
GUID: "http://example.com/posts/1",
URL: "http://example.com/posts/1",
MediaLinks: []MediaLink{
{URL: "https://example.com/path/to/image1.png", Type: "image", Description: "description 1"},
{URL: "https://example.com/path/to/image2.png", Type: "image", Description: "description 2"},
{URL: "https://example.com/path/to/video1.mp4", Type: "video", Description: "video description"},
},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid rss")
}
}

View File

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

View File

@@ -1,3 +1,5 @@
//go:build windows
package platform package platform
import ( import (
@@ -58,17 +60,20 @@ var oldStdin, oldStdout, oldStderr *os.File
// //
// Net result is as follows. // Net result is as follows.
// Before: // Before:
// SHELL NON-REDIRECTED REDIRECTED //
// explorer.exe no console n/a // SHELL NON-REDIRECTED REDIRECTED
// cmd.exe broken works // explorer.exe no console n/a
// powershell broken broken // cmd.exe broken works
// WSL bash broken works // powershell broken broken
// WSL bash broken works
//
// After // After
// SHELL NON-REDIRECTED REDIRECTED //
// explorer.exe no console n/a // SHELL NON-REDIRECTED REDIRECTED
// cmd.exe works works // explorer.exe no console n/a
// powershell works broken // cmd.exe works works
// WSL bash works works // powershell works broken
// WSL bash works works
// //
// We don't seem to make anything worse, at least. // We don't seem to make anything worse, at least.
func FixConsoleIfNeeded() error { func FixConsoleIfNeeded() error {

View File

@@ -1,4 +1,4 @@
// +build macos windows //go:build (darwin || windows) && gui
package platform package platform
@@ -10,6 +10,7 @@ import (
func Start(s *server.Server) { func Start(s *server.Server) {
systrayOnReady := func() { systrayOnReady := func() {
systray.SetIcon(Icon) systray.SetIcon(Icon)
systray.SetTooltip("yarr")
menuOpen := systray.AddMenuItem("Open", "") menuOpen := systray.AddMenuItem("Open", "")
systray.AddSeparator() systray.AddSeparator()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,393 @@
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"
)
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]interface{}, lastRefreshed int64) {
data["api_version"] = 3
data["auth"] = 1
data["last_refreshed_on_time"] = lastRefreshed
c.JSON(http.StatusOK, data)
}
func getLastRefreshedOnTime(httpStates map[int64]storage.HTTPState) int64 {
if len(httpStates) == 0 {
return 0
}
var lastRefreshed int64
for _, state := range httpStates {
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([]byte(fmt.Sprintf("%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]interface{}{
"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:
c.JSON(http.StatusOK, map[string]interface{}{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
})
}
}
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}
}
writeFeverJSON(c, map[string]interface{}{
"groups": groups,
"feeds_groups": feedGroups(s.db),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}
func (s *Server) feverFeedsHandler(c *router.Context) {
feeds := s.db.ListFeeds()
httpStates := s.db.ListHTTPStates()
feverFeeds := make([]*FeverFeed, len(feeds))
for i, feed := range feeds {
var lastUpdated int64
if state, ok := httpStates[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]interface{}{
"feeds": feverFeeds,
"feeds_groups": feedGroups(s.db),
}, getLastRefreshedOnTime(httpStates))
}
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}
}
writeFeverJSON(c, map[string]interface{}{
"favicons": favicons,
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}
// 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 := storage.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 == storage.STARRED {
isSaved = 1
}
isRead := 0
if item.Status == storage.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(storage.ItemFilter{})
writeFeverJSON(c, map[string]interface{}{
"items": feverItems,
"total_items": totalItems,
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}
func (s *Server) feverLinksHandler(c *router.Context) {
writeFeverJSON(c, map[string]interface{}{
"links": make([]interface{}, 0),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
status := storage.UNREAD
itemIds := make([]int64, 0)
itemFilter := storage.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
}
writeFeverJSON(c, map[string]interface{}{
"unread_item_ids": joinInts(itemIds),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
status := storage.STARRED
itemIds := make([]int64, 0)
itemFilter := storage.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
}
writeFeverJSON(c, map[string]interface{}{
"saved_item_ids": joinInts(itemIds),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}
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 storage.ItemStatus
switch c.Req.Form.Get("as") {
case "read":
status = storage.READ
case "unread":
status = storage.UNREAD
case "saved":
status = storage.STARRED
case "unsaved":
status = storage.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 := storage.MarkFilter{FeedID: &id}
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
if x > 0 {
before := time.Unix(x, 0)
markFilter.Before = &before
}
s.db.MarkItemsRead(markFilter)
case "group":
if c.Req.Form.Get("as") != "read" {
c.Out.WriteHeader(http.StatusBadRequest)
}
markFilter := storage.MarkFilter{FolderID: &id}
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
if x > 0 {
before := time.Unix(x, 0)
markFilter.Before = &before
}
s.db.MarkItemsRead(markFilter)
default:
c.Out.WriteHeader(http.StatusBadRequest)
return
}
c.JSON(http.StatusOK, map[string]interface{}{
"api_version": 3,
"auth": 1,
})
}

View File

@@ -32,10 +32,13 @@ func (r *Router) Use(h Handler) {
} }
func (r *Router) For(path string, handler 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 := Route{}
x.regex = routeRegexp(path) x.regex = routeRegexp(path)
x.chain = append(r.middle, handler) x.chain = chain
r.routes = append(r.routes, x) r.routes = append(r.routes, x)
} }

View File

@@ -12,6 +12,7 @@ import (
"strings" "strings"
"github.com/nkanaev/yarr/src/assets" "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/readability"
"github.com/nkanaev/yarr/src/content/sanitizer" "github.com/nkanaev/yarr/src/content/sanitizer"
"github.com/nkanaev/yarr/src/content/silo" "github.com/nkanaev/yarr/src/content/silo"
@@ -33,12 +34,14 @@ func (s *Server) handler() http.Handler {
BasePath: s.BasePath, BasePath: s.BasePath,
Username: s.Username, Username: s.Username,
Password: s.Password, Password: s.Password,
Public: "/static", Public: []string{"/static", "/fever", "/manifest.json"},
DB: s.db,
} }
r.Use(a.Handler) r.Use(a.Handler)
} }
r.For("/", s.handleIndex) r.For("/", s.handleIndex)
r.For("/manifest.json", s.handleManifest)
r.For("/static/*path", s.handleStatic) r.For("/static/*path", s.handleStatic)
r.For("/api/status", s.handleStatus) r.For("/api/status", s.handleStatus)
r.For("/api/folders", s.handleFolderList) r.For("/api/folders", s.handleFolderList)
@@ -55,6 +58,7 @@ func (s *Server) handler() http.Handler {
r.For("/opml/export", s.handleOPMLExport) r.For("/opml/export", s.handleOPMLExport)
r.For("/page", s.handlePageCrawl) r.For("/page", s.handlePageCrawl)
r.For("/logout", s.handleLogout) r.For("/logout", s.handleLogout)
r.For("/fever/", s.handleFever)
return r return r
} }
@@ -76,6 +80,24 @@ func (s *Server) handleStatic(c *router.Context) {
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]interface{}{
"$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]interface{}{
{
"src": s.BasePath + "/static/graphicarts/favicon.png",
"sizes": "64x64",
"type": "image/png",
},
},
})
}
func (s *Server) handleStatus(c *router.Context) { func (s *Server) handleStatus(c *router.Context) {
c.JSON(http.StatusOK, map[string]interface{}{ c.JSON(http.StatusOK, map[string]interface{}{
"running": s.worker.FeedsPending(), "running": s.worker.FeedsPending(),
@@ -159,7 +181,9 @@ func (s *Server) handleFeedIcon(c *router.Context) {
} }
cachekey := "icon:" + strconv.FormatInt(id, 10) cachekey := "icon:" + strconv.FormatInt(id, 10)
s.cache_mutex.Lock()
cachedat := s.cache[cachekey] cachedat := s.cache[cachekey]
s.cache_mutex.Unlock()
if cachedat == nil { if cachedat == nil {
feed := s.db.GetFeed(id) feed := s.db.GetFeed(id)
if feed == nil || feed.Icon == nil { if feed == nil || feed.Icon == nil {
@@ -177,7 +201,9 @@ func (s *Server) handleFeedIcon(c *router.Context) {
bytes: *(*feed).Icon, bytes: *(*feed).Icon,
etag: etag, etag: etag,
} }
s.cache_mutex.Lock()
s.cache[cachekey] = cachedat s.cache[cachekey] = cachedat
s.cache_mutex.Unlock()
} }
icon := cachedat.(feedicon) icon := cachedat.(feedicon)
@@ -223,6 +249,7 @@ func (s *Server) handleFeedList(c *router.Context) {
if len(items) > 0 { if len(items) > 0 {
s.db.CreateItems(items) s.db.CreateItems(items)
s.db.SetFeedSize(feed.Id, len(items)) s.db.SetFeedSize(feed.Id, len(items))
s.db.SyncSearch()
} }
s.worker.FindFeedFavicon(*feed) s.worker.FindFeedFavicon(*feed)
@@ -267,6 +294,11 @@ func (s *Server) handleFeed(c *router.Context) {
s.db.UpdateFeedFolder(id, &folderId) s.db.UpdateFeedFolder(id, &folderId)
} }
} }
if link, ok := body["feed_link"]; ok {
if reflect.TypeOf(link).Kind() == reflect.String {
s.db.UpdateFeedLink(id, link.(string))
}
}
c.Out.WriteHeader(http.StatusOK) c.Out.WriteHeader(http.StatusOK)
} else if c.Req.Method == "DELETE" { } else if c.Req.Method == "DELETE" {
s.db.DeleteFeed(id) s.db.DeleteFeed(id)
@@ -288,7 +320,18 @@ func (s *Server) handleItem(c *router.Context) {
c.Out.WriteHeader(http.StatusBadRequest) c.Out.WriteHeader(http.StatusBadRequest)
return 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) 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) c.JSON(http.StatusOK, item)
} else if c.Req.Method == "PUT" { } else if c.Req.Method == "PUT" {
@@ -331,14 +374,21 @@ func (s *Server) handleItemList(c *router.Context) {
} }
newestFirst := query.Get("oldest_first") != "true" newestFirst := query.Get("oldest_first") != "true"
items := s.db.ListItems(filter, perPage+1, newestFirst) items := s.db.ListItems(filter, perPage+1, newestFirst, true)
hasMore := false hasMore := false
if len(items) == perPage+1 { if len(items) == perPage+1 {
hasMore = true hasMore = true
items = items[:perPage] 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]interface{}{ c.JSON(http.StatusOK, map[string]interface{}{
"list": items, "list": items,
"has_more": hasMore, "has_more": hasMore,
}) })
} else if c.Req.Method == "PUT" { } else if c.Req.Method == "PUT" {
@@ -454,12 +504,19 @@ func (s *Server) handleOPMLExport(c *router.Context) {
func (s *Server) handlePageCrawl(c *router.Context) { func (s *Server) handlePageCrawl(c *router.Context) {
url := c.Req.URL.Query().Get("url") url := c.Req.URL.Query().Get("url")
if newUrl := silo.RedirectURL(url); newUrl != "" {
url = newUrl
}
if content := silo.VideoIFrame(url); content != "" { if content := silo.VideoIFrame(url); content != "" {
c.JSON(http.StatusOK, map[string]string{ c.JSON(http.StatusOK, map[string]string{
"content": sanitizer.Sanitize(url, content), "content": sanitizer.Sanitize(url, content),
}) })
return return
} }
if isInternalFromURL(url) {
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
return
}
body, err := worker.GetBody(url) body, err := worker.GetBody(url)
if err != nil { if err != nil {
@@ -469,8 +526,9 @@ func (s *Server) handlePageCrawl(c *router.Context) {
} }
content, err := readability.ExtractContent(strings.NewReader(body)) content, err := readability.ExtractContent(strings.NewReader(body))
if err != nil { if err != nil {
log.Print(err) c.JSON(http.StatusOK, map[string]string{
c.Out.WriteHeader(http.StatusNoContent) "content": "error: " + err.Error(),
})
return return
} }
content = sanitizer.Sanitize(url, content) content = sanitizer.Sanitize(url, content)

View File

@@ -2,17 +2,22 @@ package server
import ( import (
"log" "log"
"net"
"net/http" "net/http"
"os"
"strings"
"sync"
"github.com/nkanaev/yarr/src/storage" "github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/worker" "github.com/nkanaev/yarr/src/worker"
) )
type Server struct { type Server struct {
Addr string Addr string
db *storage.Storage db *storage.Storage
worker *worker.Worker worker *worker.Worker
cache map[string]interface{} cache map[string]interface{}
cache_mutex *sync.Mutex
BasePath string BasePath string
@@ -26,10 +31,11 @@ type Server struct {
func NewServer(db *storage.Storage, addr string) *Server { func NewServer(db *storage.Storage, addr string) *Server {
return &Server{ return &Server{
db: db, db: db,
Addr: addr, Addr: addr,
worker: worker.NewWorker(db), worker: worker.NewWorker(db),
cache: make(map[string]interface{}), cache: make(map[string]interface{}),
cache_mutex: &sync.Mutex{},
} }
} }
@@ -50,14 +56,31 @@ func (s *Server) Start() {
s.worker.RefreshFeeds() s.worker.RefreshFeeds()
} }
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()} var ln net.Listener
var err error 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 { } 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 { if err != http.ErrServerClosed {
log.Fatal(err) 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)
}
}
}

View File

@@ -20,18 +20,19 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
if title == "" { if title == "" {
title = feedLink title = feedLink
} }
result, err := s.db.Exec(` row := s.db.QueryRow(`
insert into feeds (title, description, link, feed_link, folder_id) insert into feeds (title, description, link, feed_link, folder_id)
values (?, ?, ?, ?, ?) values (?, ?, ?, ?, ?)
on conflict (feed_link) do update set folder_id=?`, on conflict (feed_link) do update set folder_id = ?
returning id`,
title, description, link, feedLink, folderId, title, description, link, feedLink, folderId,
folderId, folderId,
) )
var id int64
err := row.Scan(&id)
if err != nil { if err != nil {
return nil log.Print(err)
}
id, idErr := result.LastInsertId()
if idErr != nil {
return nil return nil
} }
return &Feed{ return &Feed{
@@ -70,6 +71,11 @@ func (s *Storage) UpdateFeedFolder(feedId int64, newFolderId *int64) bool {
return err == nil return err == nil
} }
func (s *Storage) UpdateFeedLink(feedId int64, newLink string) bool {
_, err := s.db.Exec(`update feeds set feed_link = ? where id = ?`, newLink, feedId)
return err == nil
}
func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool { func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId) _, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
return err == nil return err == nil

View File

@@ -17,6 +17,23 @@ func TestCreateFeed(t *testing.T) {
} }
} }
func TestCreateFeedSameLink(t *testing.T) {
db := testDB()
feed1 := db.CreateFeed("title", "", "", "http://example1.com/feed.xml", nil)
if feed1 == nil || feed1.Id == 0 {
t.Fatal("expected feed")
}
for i := 0; i < 10; i++ {
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
}
feed2 := db.CreateFeed("title", "", "http://example.com", "http://example1.com/feed.xml", nil)
if feed1.Id != feed2.Id {
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
}
}
func TestReadFeed(t *testing.T) { func TestReadFeed(t *testing.T) {
db := testDB() db := testDB()
if db.GetFeed(100500) != nil { if db.GetFeed(100500) != nil {

View File

@@ -1,7 +1,6 @@
package storage package storage
import ( import (
"fmt"
"log" "log"
) )
@@ -13,35 +12,21 @@ type Folder struct {
func (s *Storage) CreateFolder(title string) *Folder { func (s *Storage) CreateFolder(title string) *Folder {
expanded := true expanded := true
result, err := s.db.Exec(` row := s.db.QueryRow(`
insert into folders (title, is_expanded) values (?, ?) insert into folders (title, is_expanded) values (?, ?)
on conflict (title) do nothing`, on conflict (title) do update set title = ?
returning id`,
title, expanded, title, expanded,
// provide title again so that we can extract row id
title,
) )
if err != nil {
fmt.Println(err)
return nil
}
var id int64 var id int64
numrows, err := result.RowsAffected() err := row.Scan(&id)
if err != nil { if err != nil {
log.Print(err) log.Print(err)
return nil return nil
} }
if numrows == 1 {
id, err = result.LastInsertId()
if err != nil {
log.Print(err)
return nil
}
} else {
err = s.db.QueryRow(`select id, is_expanded from folders where title=?`, title).Scan(&id, &expanded)
if err != nil {
log.Print(err)
return nil
}
}
return &Folder{Id: id, Title: title, IsExpanded: expanded} return &Folder{Id: id, Title: title, IsExpanded: expanded}
} }

View File

@@ -1,9 +1,11 @@
package storage package storage
import ( import (
"database/sql/driver"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"sort"
"strings" "strings"
"time" "time"
@@ -43,17 +45,39 @@ func (s *ItemStatus) UnmarshalJSON(b []byte) error {
return nil return nil
} }
type MediaLink struct {
URL string `json:"url"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
}
type MediaLinks []MediaLink
func (m *MediaLinks) Scan(src any) error {
switch data := src.(type) {
case []byte:
return json.Unmarshal(data, m)
case string:
return json.Unmarshal([]byte(data), m)
default:
return nil
}
}
func (m MediaLinks) Value() (driver.Value, error) {
return json.Marshal(m)
}
type Item struct { type Item struct {
Id int64 `json:"id"` Id int64 `json:"id"`
GUID string `json:"guid"` GUID string `json:"guid"`
FeedId int64 `json:"feed_id"` FeedId int64 `json:"feed_id"`
Title string `json:"title"` Title string `json:"title"`
Link string `json:"link"` Link string `json:"link"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Status ItemStatus `json:"status"` Status ItemStatus `json:"status"`
ImageURL *string `json:"image"` MediaLinks MediaLinks `json:"media_links"`
AudioURL *string `json:"podcast_url"`
} }
type ItemFilter struct { type ItemFilter struct {
@@ -62,11 +86,35 @@ type ItemFilter struct {
Status *ItemStatus Status *ItemStatus
Search *string Search *string
After *int64 After *int64
IDs *[]int64
SinceID *int64
MaxID *int64
Before *time.Time
} }
type MarkFilter struct { type MarkFilter struct {
FolderID *int64 FolderID *int64
FeedID *int64 FeedID *int64
Before *time.Time
}
type ItemList []Item
func (list ItemList) Len() int {
return len(list)
}
func (list ItemList) SortKey(i int) string {
return list[i].Date.Format(time.RFC3339) + "::" + list[i].GUID
}
func (list ItemList) Less(i, j int) bool {
return list.SortKey(i) < list.SortKey(j)
}
func (list ItemList) Swap(i, j int) {
list[i], list[j] = list[j], list[i]
} }
func (s *Storage) CreateItems(items []Item) bool { func (s *Storage) CreateItems(items []Item) bool {
@@ -76,19 +124,26 @@ func (s *Storage) CreateItems(items []Item) bool {
return false return false
} }
now := time.Now() now := time.Now().UTC()
for _, item := range items { itemsSorted := ItemList(items)
sort.Sort(itemsSorted)
for _, item := range itemsSorted {
_, err = tx.Exec(` _, err = tx.Exec(`
insert into items ( insert into items (
guid, feed_id, title, link, date, guid, feed_id, title, link, date,
content, image, podcast_url, content, media_links,
date_arrived, status date_arrived, status
) )
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) values (
?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?),
?, ?,
?, ?
)
on conflict (feed_id, guid) do nothing`, on conflict (feed_id, guid) do nothing`,
item.GUID, item.FeedId, item.Title, item.Link, item.Date, item.GUID, item.FeedId, item.Title, item.Link, item.Date,
item.Content, item.ImageURL, item.AudioURL, item.Content, item.MediaLinks,
now, UNREAD, now, UNREAD,
) )
if err != nil { if err != nil {
@@ -140,6 +195,28 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare)) cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare))
args = append(args, *filter.After) args = append(args, *filter.After)
} }
if filter.IDs != nil && len(*filter.IDs) > 0 {
qmarks := make([]string, len(*filter.IDs))
idargs := make([]interface{}, len(*filter.IDs))
for i, id := range *filter.IDs {
qmarks[i] = "?"
idargs[i] = id
}
cond = append(cond, "i.id in ("+strings.Join(qmarks, ",")+")")
args = append(args, idargs...)
}
if filter.SinceID != nil {
cond = append(cond, "i.id > ?")
args = append(args, filter.SinceID)
}
if filter.MaxID != nil {
cond = append(cond, "i.id < ?")
args = append(args, filter.MaxID)
}
if filter.Before != nil {
cond = append(cond, "i.date < ?")
args = append(args, filter.Before)
}
predicate := "1" predicate := "1"
if len(cond) > 0 { if len(cond) > 0 {
@@ -149,7 +226,24 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
return predicate, args return predicate, args
} }
func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []Item { func (s *Storage) CountItems(filter ItemFilter) int {
predicate, args := listQueryPredicate(filter, false)
var count int
query := fmt.Sprintf(`
select count(*)
from items
where %s
`, predicate)
err := s.db.QueryRow(query, args...).Scan(&count)
if err != nil {
log.Print(err)
return 0
}
return count
}
func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, withContent bool) []Item {
predicate, args := listQueryPredicate(filter, newestFirst) predicate, args := listQueryPredicate(filter, newestFirst)
result := make([]Item, 0, 0) result := make([]Item, 0, 0)
@@ -157,17 +251,26 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []It
if !newestFirst { if !newestFirst {
order = "date asc, id asc" order = "date asc, id asc"
} }
if filter.IDs != nil || filter.SinceID != nil {
order = "i.id asc"
}
if filter.MaxID != nil {
order = "i.id desc"
}
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
if withContent {
selectCols += ", i.content"
} else {
selectCols += ", '' as content"
}
query := fmt.Sprintf(` query := fmt.Sprintf(`
select select %s
i.id, i.guid, i.feed_id,
i.title, i.link, i.date,
i.status, i.image, i.podcast_url
from items i from items i
where %s where %s
order by %s order by %s
limit %d limit %d
`, predicate, order, limit) `, selectCols, predicate, order, limit)
rows, err := s.db.Query(query, args...) rows, err := s.db.Query(query, args...)
if err != nil { if err != nil {
log.Print(err) log.Print(err)
@@ -178,7 +281,7 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []It
err = rows.Scan( err = rows.Scan(
&x.Id, &x.GUID, &x.FeedId, &x.Id, &x.GUID, &x.FeedId,
&x.Title, &x.Link, &x.Date, &x.Title, &x.Link, &x.Date,
&x.Status, &x.ImageURL, &x.AudioURL, &x.Status, &x.MediaLinks, &x.Content,
) )
if err != nil { if err != nil {
log.Print(err) log.Print(err)
@@ -194,12 +297,12 @@ func (s *Storage) GetItem(id int64) *Item {
err := s.db.QueryRow(` err := s.db.QueryRow(`
select select
i.id, i.guid, i.feed_id, i.title, i.link, i.content, i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.image, i.podcast_url i.date, i.status, i.media_links
from items i from items i
where i.id = ? where i.id = ?
`, id).Scan( `, id).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content, &i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL, &i.Date, &i.Status, &i.MediaLinks,
) )
if err != nil { if err != nil {
log.Print(err) log.Print(err)
@@ -214,7 +317,11 @@ func (s *Storage) UpdateItemStatus(item_id int64, status ItemStatus) bool {
} }
func (s *Storage) MarkItemsRead(filter MarkFilter) bool { func (s *Storage) MarkItemsRead(filter MarkFilter) bool {
predicate, args := listQueryPredicate(ItemFilter{FolderID: filter.FolderID, FeedID: filter.FeedID}, false) predicate, args := listQueryPredicate(ItemFilter{
FolderID: filter.FolderID,
FeedID: filter.FeedID,
Before: filter.Before,
}, false)
query := fmt.Sprintf(` query := fmt.Sprintf(`
update items as i set status = %d update items as i set status = %d
where %s and i.status != %d where %s and i.status != %d
@@ -300,11 +407,11 @@ var (
// Delete old articles from the database to cleanup space. // Delete old articles from the database to cleanup space.
// //
// The rules: // The rules:
// * Never delete starred entries. // - Never delete starred entries.
// * Keep at least the same amount of articles the feed provides (default: 50). // - Keep at least the same amount of articles the feed provides (default: 50).
// This prevents from deleting items for rarely updated and/or ever-growing // This prevents from deleting items for rarely updated and/or ever-growing
// feeds which might eventually reappear as unread. // feeds which might eventually reappear as unread.
// * Keep entries for a certain period (default: 90 days). // - Keep entries for a certain period (default: 90 days).
func (s *Storage) DeleteOldItems() { func (s *Storage) DeleteOldItems() {
rows, err := s.db.Query(` rows, err := s.db.Query(`
select select
@@ -316,7 +423,6 @@ func (s *Storage) DeleteOldItems() {
where status != ? where status != ?
group by i.feed_id group by i.feed_id
`, itemsKeepSize, STARRED) `, itemsKeepSize, STARRED)
if err != nil { if err != nil {
log.Print(err) log.Print(err)
return return
@@ -343,7 +449,7 @@ func (s *Storage) DeleteOldItems() {
feedId, feedId,
STARRED, STARRED,
limit, limit,
time.Now().Add(-time.Hour*time.Duration(24*itemsKeepDays)), time.Now().UTC().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
) )
if err != nil { if err != nil {
log.Print(err) log.Print(err)

View File

@@ -77,12 +77,12 @@ func getItem(db *Storage, guid string) *Item {
err := db.db.QueryRow(` err := db.db.QueryRow(`
select select
i.id, i.guid, i.feed_id, i.title, i.link, i.content, i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.image, i.podcast_url i.date, i.status, i.media_links
from items i from items i
where i.guid = ? where i.guid = ?
`, guid).Scan( `, guid).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content, &i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL, &i.Date, &i.Status, &i.MediaLinks,
) )
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -104,7 +104,7 @@ func TestListItems(t *testing.T) {
// filter by folder_id // filter by folder_id
have := getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 10, false)) have := getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 10, false, false))
want := []string{"item111", "item112", "item113", "item121", "item122"} want := []string{"item111", "item112", "item113", "item121", "item122"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -112,7 +112,7 @@ func TestListItems(t *testing.T) {
t.Fail() t.Fail()
} }
have = getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 10, false)) have = getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 10, false, false))
want = []string{"item211", "item212"} want = []string{"item211", "item212"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -122,7 +122,7 @@ func TestListItems(t *testing.T) {
// filter by feed_id // filter by feed_id
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 10, false)) have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 10, false, false))
want = []string{"item111", "item112", "item113"} want = []string{"item111", "item112", "item113"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -130,7 +130,7 @@ func TestListItems(t *testing.T) {
t.Fail() t.Fail()
} }
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 10, false)) have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 10, false, false))
want = []string{"item011", "item012", "item013"} want = []string{"item011", "item012", "item013"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -141,7 +141,7 @@ func TestListItems(t *testing.T) {
// filter by status // filter by status
var starred ItemStatus = STARRED var starred ItemStatus = STARRED
have = getItemGuids(db.ListItems(ItemFilter{Status: &starred}, 10, false)) have = getItemGuids(db.ListItems(ItemFilter{Status: &starred}, 10, false, false))
want = []string{"item113", "item212", "item013"} want = []string{"item113", "item212", "item013"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -150,7 +150,7 @@ func TestListItems(t *testing.T) {
} }
var unread ItemStatus = UNREAD var unread ItemStatus = UNREAD
have = getItemGuids(db.ListItems(ItemFilter{Status: &unread}, 10, false)) have = getItemGuids(db.ListItems(ItemFilter{Status: &unread}, 10, false, false))
want = []string{"item111", "item121", "item011"} want = []string{"item111", "item121", "item011"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -160,7 +160,7 @@ func TestListItems(t *testing.T) {
// limit // limit
have = getItemGuids(db.ListItems(ItemFilter{}, 2, false)) have = getItemGuids(db.ListItems(ItemFilter{}, 2, false, false))
want = []string{"item111", "item112"} want = []string{"item111", "item112"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -171,7 +171,7 @@ func TestListItems(t *testing.T) {
// filter by search // filter by search
db.SyncSearch() db.SyncSearch()
search1 := "title111" search1 := "title111"
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true)) have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
want = []string{"item111"} want = []string{"item111"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -180,7 +180,7 @@ func TestListItems(t *testing.T) {
} }
// sort by date // sort by date
have = getItemGuids(db.ListItems(ItemFilter{}, 4, true)) have = getItemGuids(db.ListItems(ItemFilter{}, 4, true, false))
want = []string{"item013", "item012", "item011", "item212"} want = []string{"item013", "item012", "item011", "item212"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -197,7 +197,7 @@ func TestListItemsPaginated(t *testing.T) {
item121 := getItem(db, "item121") item121 := getItem(db, "item121")
// all, newest first // all, newest first
have := getItemGuids(db.ListItems(ItemFilter{After: &item012.Id}, 3, true)) have := getItemGuids(db.ListItems(ItemFilter{After: &item012.Id}, 3, true, false))
want := []string{"item011", "item212", "item211"} want := []string{"item011", "item212", "item211"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -207,7 +207,7 @@ func TestListItemsPaginated(t *testing.T) {
// unread, newest first // unread, newest first
unread := UNREAD unread := UNREAD
have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true)) have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false))
want = []string{"item011", "item121", "item111"} want = []string{"item011", "item121", "item111"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -217,7 +217,7 @@ func TestListItemsPaginated(t *testing.T) {
// starred, oldest first // starred, oldest first
starred := STARRED starred := STARRED
have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false)) have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false))
want = []string{"item212", "item013"} want = []string{"item212", "item013"}
if !reflect.DeepEqual(have, want) { if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want) t.Logf("want: %#v", want)
@@ -233,7 +233,7 @@ func TestMarkItemsRead(t *testing.T) {
db1 := testDB() db1 := testDB()
testItemsSetup(db1) testItemsSetup(db1)
db1.MarkItemsRead(MarkFilter{}) db1.MarkItemsRead(MarkFilter{})
have := getItemGuids(db1.ListItems(ItemFilter{Status: &read}, 10, false)) have := getItemGuids(db1.ListItems(ItemFilter{Status: &read}, 10, false, false))
want := []string{ want := []string{
"item111", "item112", "item121", "item122", "item111", "item112", "item121", "item122",
"item211", "item011", "item012", "item211", "item011", "item012",
@@ -247,7 +247,7 @@ func TestMarkItemsRead(t *testing.T) {
db2 := testDB() db2 := testDB()
scope2 := testItemsSetup(db2) scope2 := testItemsSetup(db2)
db2.MarkItemsRead(MarkFilter{FolderID: &scope2.folder1.Id}) db2.MarkItemsRead(MarkFilter{FolderID: &scope2.folder1.Id})
have = getItemGuids(db2.ListItems(ItemFilter{Status: &read}, 10, false)) have = getItemGuids(db2.ListItems(ItemFilter{Status: &read}, 10, false, false))
want = []string{ want = []string{
"item111", "item112", "item121", "item122", "item111", "item112", "item121", "item122",
"item211", "item012", "item211", "item012",
@@ -261,7 +261,7 @@ func TestMarkItemsRead(t *testing.T) {
db3 := testDB() db3 := testDB()
scope3 := testItemsSetup(db3) scope3 := testItemsSetup(db3)
db3.MarkItemsRead(MarkFilter{FeedID: &scope3.feed11.Id}) db3.MarkItemsRead(MarkFilter{FeedID: &scope3.feed11.Id})
have = getItemGuids(db3.ListItems(ItemFilter{Status: &read}, 10, false)) have = getItemGuids(db3.ListItems(ItemFilter{Status: &read}, 10, false, false))
want = []string{ want = []string{
"item111", "item112", "item122", "item111", "item112", "item122",
"item211", "item012", "item211", "item012",
@@ -276,7 +276,7 @@ func TestMarkItemsRead(t *testing.T) {
func TestDeleteOldItems(t *testing.T) { func TestDeleteOldItems(t *testing.T) {
extraItems := 10 extraItems := 10
now := time.Now() now := time.Now().UTC()
db := testDB() db := testDB()
feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil) feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil)
@@ -291,7 +291,7 @@ func TestDeleteOldItems(t *testing.T) {
}) })
} }
db.CreateItems(items) db.CreateItems(items)
db.SetFeedSize(feed.Id, itemsKeepSize) db.SetFeedSize(feed.Id, itemsKeepSize)
var feedSize int var feedSize int
err := db.db.QueryRow( err := db.db.QueryRow(
@@ -302,7 +302,7 @@ func TestDeleteOldItems(t *testing.T) {
} }
if feedSize != itemsKeepSize { if feedSize != itemsKeepSize {
t.Fatalf( t.Fatalf(
"expected feed size to get updated\nwant: %d\nhave: %d", "expected feed size to get updated\nwant: %d\nhave: %d",
itemsKeepSize+extraItems, itemsKeepSize+extraItems,
feedSize, feedSize,
) )
@@ -319,7 +319,7 @@ func TestDeleteOldItems(t *testing.T) {
} }
db.DeleteOldItems() db.DeleteOldItems()
feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false) feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false, false)
if len(feedItems) != len(items)-3 { if len(feedItems) != len(items)-3 {
t.Fatalf( t.Fatalf(
"invalid number of old items kept\nwant: %d\nhave: %d", "invalid number of old items kept\nwant: %d\nhave: %d",

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log" "log"
"time"
) )
var migrations = []func(*sql.Tx) error{ var migrations = []func(*sql.Tx) error{
@@ -14,13 +15,18 @@ var migrations = []func(*sql.Tx) error{
m05_move_description_to_content, m05_move_description_to_content,
m06_fill_missing_dates, m06_fill_missing_dates,
m07_add_feed_size, m07_add_feed_size,
m08_normalize_datetime,
m09_change_item_index,
m10_add_item_medialinks,
} }
var maxVersion = int64(len(migrations)) var maxVersion = int64(len(migrations))
func migrate(db *sql.DB) error { func migrate(db *sql.DB) error {
var version int64 var version int64
db.QueryRow("pragma user_version").Scan(&version) if err := db.QueryRow("pragma user_version").Scan(&version); err != nil {
return err
}
if version >= maxVersion { if version >= maxVersion {
return nil return nil
@@ -271,3 +277,55 @@ func m07_add_feed_size(tx *sql.Tx) error {
_, err := tx.Exec(sql) _, err := tx.Exec(sql)
return err return err
} }
func m08_normalize_datetime(tx *sql.Tx) error {
rows, err := tx.Query(`select id, date_arrived from items;`)
if err != nil {
return err
}
for rows.Next() {
var id int64
var dateArrived time.Time
err = rows.Scan(&id, &dateArrived)
if err != nil {
return err
}
_, err = tx.Exec(`update items set date_arrived = ? where id = ?;`, dateArrived.UTC(), id)
if err != nil {
return err
}
}
_, err = tx.Exec(`update items set date = strftime('%Y-%m-%d %H:%M:%f', date);`)
return err
}
func m09_change_item_index(tx *sql.Tx) error {
sql := `
drop index if exists idx_item_status;
create index if not exists idx_item__date_id_status on items(date,id,status);
`
_, err := tx.Exec(sql)
return err
}
func m10_add_item_medialinks(tx *sql.Tx) error {
sql := `
alter table items add column media_links json;
update items set media_links = case
when coalesce(image, '') != '' and coalesce(podcast_url, '') != ''
then json_array(json_object('type', 'image', 'url', image), json_object('type', 'audio', 'url', podcast_url))
when coalesce(image, '') != ''
then json_array(json_object('type', 'image', 'url', image))
when coalesce(podcast_url, '') != ''
then json_array(json_object('type', 'audio', 'url', podcast_url))
else null
end;
alter table items drop column image;
alter table items drop column podcast_url;
`
_, err := tx.Exec(sql)
return err
}

View File

@@ -2,6 +2,9 @@ package storage
import ( import (
"database/sql" "database/sql"
"log"
"strings"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -10,14 +13,17 @@ type Storage struct {
} }
func New(path string) (*Storage, error) { func New(path string) (*Storage, error) {
if pos := strings.IndexRune(path, '?'); pos == -1 {
params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"
log.Printf("opening db with params: %s", params)
path = path + "?" + params
}
db, err := sql.Open("sqlite3", path) db, err := sql.Open("sqlite3", path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// TODO: https://foxcpp.dev/articles/the-right-way-to-use-go-sqlite3
db.SetMaxOpenConns(1)
if err = migrate(db); err != nil { if err = migrate(db); err != nil {
return nil, err return nil, err
} }

View File

@@ -1,3 +1,4 @@
//go:build darwin || windows
// +build darwin windows // +build darwin windows
/* /*

View File

@@ -1,3 +1,4 @@
//go:build never
// +build never // +build never
package systray package systray

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,10 @@ func (c *Client) getConditional(url, lastModified, etag string) (*http.Response,
var client *Client var client *Client
func SetVersion(num string) {
client.userAgent = "Yarr/" + num
}
func init() { func init() {
transport := &http.Transport{ transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,

View File

@@ -143,24 +143,19 @@ func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
result := make([]storage.Item, len(items)) result := make([]storage.Item, len(items))
for i, item := range items { for i, item := range items {
item := item item := item
var audioURL *string = nil mediaLinks := make(storage.MediaLinks, 0)
if item.AudioURL != "" { for _, link := range item.MediaLinks {
audioURL = &item.AudioURL mediaLinks = append(mediaLinks, storage.MediaLink(link))
}
var imageURL *string = nil
if item.ImageURL != "" {
imageURL = &item.ImageURL
} }
result[i] = storage.Item{ result[i] = storage.Item{
GUID: item.GUID, GUID: item.GUID,
FeedId: feed.Id, FeedId: feed.Id,
Title: item.Title, Title: item.Title,
Link: item.URL, Link: item.URL,
Content: item.Content, Content: item.Content,
Date: item.Date, Date: item.Date,
Status: storage.UNREAD, Status: storage.UNREAD,
ImageURL: imageURL, MediaLinks: mediaLinks,
AudioURL: audioURL,
} }
} }
return result return result

4
vendor/github.com/mattn/go-sqlite3/.codecov.yml generated vendored Normal file
View File

@@ -0,0 +1,4 @@
coverage:
status:
project: off
patch: off

14
vendor/github.com/mattn/go-sqlite3/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,14 @@
*.db
*.exe
*.dll
*.o
# VSCode
.vscode
# Exclude from upgrade
upgrade/*.c
upgrade/*.h
# Exclude upgrade binary
upgrade/upgrade

21
vendor/github.com/mattn/go-sqlite3/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Yasuhiro Matsumoto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

603
vendor/github.com/mattn/go-sqlite3/README.md generated vendored Normal file
View File

@@ -0,0 +1,603 @@
go-sqlite3
==========
[![Go Reference](https://pkg.go.dev/badge/github.com/mattn/go-sqlite3.svg)](https://pkg.go.dev/github.com/mattn/go-sqlite3)
[![GitHub Actions](https://github.com/mattn/go-sqlite3/workflows/Go/badge.svg)](https://github.com/mattn/go-sqlite3/actions?query=workflow%3AGo)
[![Financial Contributors on Open Collective](https://opencollective.com/mattn-go-sqlite3/all/badge.svg?label=financial+contributors)](https://opencollective.com/mattn-go-sqlite3)
[![codecov](https://codecov.io/gh/mattn/go-sqlite3/branch/master/graph/badge.svg)](https://codecov.io/gh/mattn/go-sqlite3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mattn/go-sqlite3)](https://goreportcard.com/report/github.com/mattn/go-sqlite3)
Latest stable version is v1.14 or later, not v2.
~~**NOTE:** The increase to v2 was an accident. There were no major changes or features.~~
# Description
A sqlite3 driver that conforms to the built-in database/sql interface.
Supported Golang version: See [.github/workflows/go.yaml](./.github/workflows/go.yaml).
This package follows the official [Golang Release Policy](https://golang.org/doc/devel/release.html#policy).
### Overview
- [go-sqlite3](#go-sqlite3)
- [Description](#description)
- [Overview](#overview)
- [Installation](#installation)
- [API Reference](#api-reference)
- [Connection String](#connection-string)
- [DSN Examples](#dsn-examples)
- [Features](#features)
- [Usage](#usage)
- [Feature / Extension List](#feature--extension-list)
- [Compilation](#compilation)
- [Android](#android)
- [ARM](#arm)
- [Cross Compile](#cross-compile)
- [Google Cloud Platform](#google-cloud-platform)
- [Linux](#linux)
- [Alpine](#alpine)
- [Fedora](#fedora)
- [Ubuntu](#ubuntu)
- [macOS](#mac-osx)
- [Windows](#windows)
- [Errors](#errors)
- [User Authentication](#user-authentication)
- [Compile](#compile)
- [Usage](#usage-1)
- [Create protected database](#create-protected-database)
- [Password Encoding](#password-encoding)
- [Available Encoders](#available-encoders)
- [Restrictions](#restrictions)
- [Support](#support)
- [User Management](#user-management)
- [SQL](#sql)
- [Examples](#examples)
- [*SQLiteConn](#sqliteconn)
- [Attached database](#attached-database)
- [Extensions](#extensions)
- [Spatialite](#spatialite)
- [FAQ](#faq)
- [License](#license)
- [Author](#author)
# Installation
This package can be installed with the `go get` command:
go get github.com/mattn/go-sqlite3
_go-sqlite3_ is *cgo* package.
If you want to build your app using go-sqlite3, you need gcc.
However, after you have built and installed _go-sqlite3_ with `go install github.com/mattn/go-sqlite3` (which requires gcc), you can build your app without relying on gcc in future.
***Important: because this is a `CGO` enabled package, you are required to set the environment variable `CGO_ENABLED=1` and have a `gcc` compiler present within your path.***
# API Reference
API documentation can be found [here](http://godoc.org/github.com/mattn/go-sqlite3).
Examples can be found under the [examples](./_example) directory.
# Connection String
When creating a new SQLite database or connection to an existing one, with the file name additional options can be given.
This is also known as a DSN (Data Source Name) string.
Options are append after the filename of the SQLite database.
The database filename and options are separated by an `?` (Question Mark).
Options should be URL-encoded (see [url.QueryEscape](https://golang.org/pkg/net/url/#QueryEscape)).
This also applies when using an in-memory database instead of a file.
Options can be given using the following format: `KEYWORD=VALUE` and multiple options can be combined with the `&` ampersand.
This library supports DSN options of SQLite itself and provides additional options.
Boolean values can be one of:
* `0` `no` `false` `off`
* `1` `yes` `true` `on`
| Name | Key | Value(s) | Description |
|------|-----|----------|-------------|
| UA - Create | `_auth` | - | Create User Authentication, for more information see [User Authentication](#user-authentication) |
| UA - Username | `_auth_user` | `string` | Username for User Authentication, for more information see [User Authentication](#user-authentication) |
| UA - Password | `_auth_pass` | `string` | Password for User Authentication, for more information see [User Authentication](#user-authentication) |
| UA - Crypt | `_auth_crypt` | <ul><li>SHA1</li><li>SSHA1</li><li>SHA256</li><li>SSHA256</li><li>SHA384</li><li>SSHA384</li><li>SHA512</li><li>SSHA512</li></ul> | Password encoder to use for User Authentication, for more information see [User Authentication](#user-authentication) |
| UA - Salt | `_auth_salt` | `string` | Salt to use if the configure password encoder requires a salt, for User Authentication, for more information see [User Authentication](#user-authentication) |
| Auto Vacuum | `_auto_vacuum` \| `_vacuum` | <ul><li>`0` \| `none`</li><li>`1` \| `full`</li><li>`2` \| `incremental`</li></ul> | For more information see [PRAGMA auto_vacuum](https://www.sqlite.org/pragma.html#pragma_auto_vacuum) |
| Busy Timeout | `_busy_timeout` \| `_timeout` | `int` | Specify value for sqlite3_busy_timeout. For more information see [PRAGMA busy_timeout](https://www.sqlite.org/pragma.html#pragma_busy_timeout) |
| Case Sensitive LIKE | `_case_sensitive_like` \| `_cslike` | `boolean` | For more information see [PRAGMA case_sensitive_like](https://www.sqlite.org/pragma.html#pragma_case_sensitive_like) |
| Defer Foreign Keys | `_defer_foreign_keys` \| `_defer_fk` | `boolean` | For more information see [PRAGMA defer_foreign_keys](https://www.sqlite.org/pragma.html#pragma_defer_foreign_keys) |
| Foreign Keys | `_foreign_keys` \| `_fk` | `boolean` | For more information see [PRAGMA foreign_keys](https://www.sqlite.org/pragma.html#pragma_foreign_keys) |
| Ignore CHECK Constraints | `_ignore_check_constraints` | `boolean` | For more information see [PRAGMA ignore_check_constraints](https://www.sqlite.org/pragma.html#pragma_ignore_check_constraints) |
| Immutable | `immutable` | `boolean` | For more information see [Immutable](https://www.sqlite.org/c3ref/open.html) |
| Journal Mode | `_journal_mode` \| `_journal` | <ul><li>DELETE</li><li>TRUNCATE</li><li>PERSIST</li><li>MEMORY</li><li>WAL</li><li>OFF</li></ul> | For more information see [PRAGMA journal_mode](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| Locking Mode | `_locking_mode` \| `_locking` | <ul><li>NORMAL</li><li>EXCLUSIVE</li></ul> | For more information see [PRAGMA locking_mode](https://www.sqlite.org/pragma.html#pragma_locking_mode) |
| Mode | `mode` | <ul><li>ro</li><li>rw</li><li>rwc</li><li>memory</li></ul> | Access Mode of the database. For more information see [SQLite Open](https://www.sqlite.org/c3ref/open.html) |
| Mutex Locking | `_mutex` | <ul><li>no</li><li>full</li></ul> | Specify mutex mode. |
| Query Only | `_query_only` | `boolean` | For more information see [PRAGMA query_only](https://www.sqlite.org/pragma.html#pragma_query_only) |
| Recursive Triggers | `_recursive_triggers` \| `_rt` | `boolean` | For more information see [PRAGMA recursive_triggers](https://www.sqlite.org/pragma.html#pragma_recursive_triggers) |
| Secure Delete | `_secure_delete` | `boolean` \| `FAST` | For more information see [PRAGMA secure_delete](https://www.sqlite.org/pragma.html#pragma_secure_delete) |
| Shared-Cache Mode | `cache` | <ul><li>shared</li><li>private</li></ul> | Set cache mode for more information see [sqlite.org](https://www.sqlite.org/sharedcache.html) |
| Synchronous | `_synchronous` \| `_sync` | <ul><li>0 \| OFF</li><li>1 \| NORMAL</li><li>2 \| FULL</li><li>3 \| EXTRA</li></ul> | For more information see [PRAGMA synchronous](https://www.sqlite.org/pragma.html#pragma_synchronous) |
| Time Zone Location | `_loc` | auto | Specify location of time format. |
| Transaction Lock | `_txlock` | <ul><li>immediate</li><li>deferred</li><li>exclusive</li></ul> | Specify locking behavior for transactions. |
| Writable Schema | `_writable_schema` | `Boolean` | When this pragma is on, the SQLITE_MASTER tables in which database can be changed using ordinary UPDATE, INSERT, and DELETE statements. Warning: misuse of this pragma can easily result in a corrupt database file. |
| Cache Size | `_cache_size` | `int` | Maximum cache size; default is 2000K (2M). See [PRAGMA cache_size](https://sqlite.org/pragma.html#pragma_cache_size) |
## DSN Examples
```
file:test.db?cache=shared&mode=memory
```
# Features
This package allows additional configuration of features available within SQLite3 to be enabled or disabled by golang build constraints also known as build `tags`.
Click [here](https://golang.org/pkg/go/build/#hdr-Build_Constraints) for more information about build tags / constraints.
### Usage
If you wish to build this library with additional extensions / features, use the following command:
```bash
go build -tags "<FEATURE>"
```
For available features, see the extension list.
When using multiple build tags, all the different tags should be space delimited.
Example:
```bash
go build -tags "icu json1 fts5 secure_delete"
```
### Feature / Extension List
| Extension | Build Tag | Description |
|-----------|-----------|-------------|
| Additional Statistics | sqlite_stat4 | This option adds additional logic to the ANALYZE command and to the query planner that can help SQLite to chose a better query plan under certain situations. The ANALYZE command is enhanced to collect histogram data from all columns of every index and store that data in the sqlite_stat4 table.<br><br>The query planner will then use the histogram data to help it make better index choices. The downside of this compile-time option is that it violates the query planner stability guarantee making it more difficult to ensure consistent performance in mass-produced applications.<br><br>SQLITE_ENABLE_STAT4 is an enhancement of SQLITE_ENABLE_STAT3. STAT3 only recorded histogram data for the left-most column of each index whereas the STAT4 enhancement records histogram data from all columns of each index.<br><br>The SQLITE_ENABLE_STAT3 compile-time option is a no-op and is ignored if the SQLITE_ENABLE_STAT4 compile-time option is used |
| Allow URI Authority | sqlite_allow_uri_authority | URI filenames normally throws an error if the authority section is not either empty or "localhost".<br><br>However, if SQLite is compiled with the SQLITE_ALLOW_URI_AUTHORITY compile-time option, then the URI is converted into a Uniform Naming Convention (UNC) filename and passed down to the underlying operating system that way |
| App Armor | sqlite_app_armor | When defined, this C-preprocessor macro activates extra code that attempts to detect misuse of the SQLite API, such as passing in NULL pointers to required parameters or using objects after they have been destroyed. <br><br>App Armor is not available under `Windows`. |
| Disable Load Extensions | sqlite_omit_load_extension | Loading of external extensions is enabled by default.<br><br>To disable extension loading add the build tag `sqlite_omit_load_extension`. |
| Enable Serialization with `libsqlite3` | sqlite_serialize | Serialization and deserialization of a SQLite database is available by default, unless the build tag `libsqlite3` is set.<br><br>To enable this functionality even if `libsqlite3` is set, add the build tag `sqlite_serialize`. |
| Foreign Keys | sqlite_foreign_keys | This macro determines whether enforcement of foreign key constraints is enabled or disabled by default for new database connections.<br><br>Each database connection can always turn enforcement of foreign key constraints on and off and run-time using the foreign_keys pragma.<br><br>Enforcement of foreign key constraints is normally off by default, but if this compile-time parameter is set to 1, enforcement of foreign key constraints will be on by default |
| Full Auto Vacuum | sqlite_vacuum_full | Set the default auto vacuum to full |
| Incremental Auto Vacuum | sqlite_vacuum_incr | Set the default auto vacuum to incremental |
| Full Text Search Engine | sqlite_fts5 | When this option is defined in the amalgamation, versions 5 of the full-text search engine (fts5) is added to the build automatically |
| International Components for Unicode | sqlite_icu | This option causes the International Components for Unicode or "ICU" extension to SQLite to be added to the build |
| Introspect PRAGMAS | sqlite_introspect | This option adds some extra PRAGMA statements. <ul><li>PRAGMA function_list</li><li>PRAGMA module_list</li><li>PRAGMA pragma_list</li></ul> |
| JSON SQL Functions | sqlite_json | When this option is defined in the amalgamation, the JSON SQL functions are added to the build automatically |
| Math Functions | sqlite_math_functions | This compile-time option enables built-in scalar math functions. For more information see [Built-In Mathematical SQL Functions](https://www.sqlite.org/lang_mathfunc.html) |
| OS Trace | sqlite_os_trace | This option enables OSTRACE() debug logging. This can be verbose and should not be used in production. |
| Pre Update Hook | sqlite_preupdate_hook | Registers a callback function that is invoked prior to each INSERT, UPDATE, and DELETE operation on a database table. |
| Secure Delete | sqlite_secure_delete | This compile-time option changes the default setting of the secure_delete pragma.<br><br>When this option is not used, secure_delete defaults to off. When this option is present, secure_delete defaults to on.<br><br>The secure_delete setting causes deleted content to be overwritten with zeros. There is a small performance penalty since additional I/O must occur.<br><br>On the other hand, secure_delete can prevent fragments of sensitive information from lingering in unused parts of the database file after it has been deleted. See the documentation on the secure_delete pragma for additional information |
| Secure Delete (FAST) | sqlite_secure_delete_fast | For more information see [PRAGMA secure_delete](https://www.sqlite.org/pragma.html#pragma_secure_delete) |
| Tracing / Debug | sqlite_trace | Activate trace functions |
| User Authentication | sqlite_userauth | SQLite User Authentication see [User Authentication](#user-authentication) for more information. |
| Virtual Tables | sqlite_vtable | SQLite Virtual Tables see [SQLite Official VTABLE Documentation](https://www.sqlite.org/vtab.html) for more information, and a [full example here](https://github.com/mattn/go-sqlite3/tree/master/_example/vtable) |
# Compilation
This package requires the `CGO_ENABLED=1` environment variable if not set by default, and the presence of the `gcc` compiler.
If you need to add additional CFLAGS or LDFLAGS to the build command, and do not want to modify this package, then this can be achieved by using the `CGO_CFLAGS` and `CGO_LDFLAGS` environment variables.
## Android
This package can be compiled for android.
Compile with:
```bash
go build -tags "android"
```
For more information see [#201](https://github.com/mattn/go-sqlite3/issues/201)
# ARM
To compile for `ARM` use the following environment:
```bash
env CC=arm-linux-gnueabihf-gcc CXX=arm-linux-gnueabihf-g++ \
CGO_ENABLED=1 GOOS=linux GOARCH=arm GOARM=7 \
go build -v
```
Additional information:
- [#242](https://github.com/mattn/go-sqlite3/issues/242)
- [#504](https://github.com/mattn/go-sqlite3/issues/504)
# Cross Compile
This library can be cross-compiled.
In some cases you are required to the `CC` environment variable with the cross compiler.
## Cross Compiling from macOS
The simplest way to cross compile from macOS is to use [xgo](https://github.com/karalabe/xgo).
Steps:
- Install [musl-cross](https://github.com/FiloSottile/homebrew-musl-cross) (`brew install FiloSottile/musl-cross/musl-cross`).
- Run `CC=x86_64-linux-musl-gcc CXX=x86_64-linux-musl-g++ GOARCH=amd64 GOOS=linux CGO_ENABLED=1 go build -ldflags "-linkmode external -extldflags -static"`.
Please refer to the project's [README](https://github.com/FiloSottile/homebrew-musl-cross#readme) for further information.
# Google Cloud Platform
Building on GCP is not possible because Google Cloud Platform does not allow `gcc` to be executed.
Please work only with compiled final binaries.
## Linux
To compile this package on Linux, you must install the development tools for your linux distribution.
To compile under linux use the build tag `linux`.
```bash
go build -tags "linux"
```
If you wish to link directly to libsqlite3 then you can use the `libsqlite3` build tag.
```
go build -tags "libsqlite3 linux"
```
### Alpine
When building in an `alpine` container run the following command before building:
```
apk add --update gcc musl-dev
```
### Fedora
```bash
sudo yum groupinstall "Development Tools" "Development Libraries"
```
### Ubuntu
```bash
sudo apt-get install build-essential
```
## macOS
macOS should have all the tools present to compile this package. If not, install XCode to add all the developers tools.
Required dependency:
```bash
brew install sqlite3
```
For macOS, there is an additional package to install which is required if you wish to build the `icu` extension.
This additional package can be installed with `homebrew`:
```bash
brew upgrade icu4c
```
To compile for macOS on x86:
```bash
go build -tags "darwin amd64"
```
To compile for macOS on ARM chips:
```bash
go build -tags "darwin arm64"
```
If you wish to link directly to libsqlite3, use the `libsqlite3` build tag:
```
# x86
go build -tags "libsqlite3 darwin amd64"
# ARM
go build -tags "libsqlite3 darwin arm64"
```
Additional information:
- [#206](https://github.com/mattn/go-sqlite3/issues/206)
- [#404](https://github.com/mattn/go-sqlite3/issues/404)
## Windows
To compile this package on Windows, you must have the `gcc` compiler installed.
1) Install a Windows `gcc` toolchain.
2) Add the `bin` folder to the Windows path, if the installer did not do this by default.
3) Open a terminal for the TDM-GCC toolchain, which can be found in the Windows Start menu.
4) Navigate to your project folder and run the `go build ...` command for this package.
For example the TDM-GCC Toolchain can be found [here](https://jmeubank.github.io/tdm-gcc/).
## Errors
- Compile error: `can not be used when making a shared object; recompile with -fPIC`
When receiving a compile time error referencing recompile with `-FPIC` then you
are probably using a hardend system.
You can compile the library on a hardend system with the following command.
```bash
go build -ldflags '-extldflags=-fno-PIC'
```
More details see [#120](https://github.com/mattn/go-sqlite3/issues/120)
- Can't build go-sqlite3 on windows 64bit.
> Probably, you are using go 1.0, go1.0 has a problem when it comes to compiling/linking on windows 64bit.
> See: [#27](https://github.com/mattn/go-sqlite3/issues/27)
- `go get github.com/mattn/go-sqlite3` throws compilation error.
`gcc` throws: `internal compiler error`
Remove the download repository from your disk and try re-install with:
```bash
go install github.com/mattn/go-sqlite3
```
# User Authentication
This package supports the SQLite User Authentication module.
## Compile
To use the User authentication module, the package has to be compiled with the tag `sqlite_userauth`. See [Features](#features).
## Usage
### Create protected database
To create a database protected by user authentication, provide the following argument to the connection string `_auth`.
This will enable user authentication within the database. This option however requires two additional arguments:
- `_auth_user`
- `_auth_pass`
When `_auth` is present in the connection string user authentication will be enabled and the provided user will be created
as an `admin` user. After initial creation, the parameter `_auth` has no effect anymore and can be omitted from the connection string.
Example connection strings:
Create an user authentication database with user `admin` and password `admin`:
`file:test.s3db?_auth&_auth_user=admin&_auth_pass=admin`
Create an user authentication database with user `admin` and password `admin` and use `SHA1` for the password encoding:
`file:test.s3db?_auth&_auth_user=admin&_auth_pass=admin&_auth_crypt=sha1`
### Password Encoding
The passwords within the user authentication module of SQLite are encoded with the SQLite function `sqlite_cryp`.
This function uses a ceasar-cypher which is quite insecure.
This library provides several additional password encoders which can be configured through the connection string.
The password cypher can be configured with the key `_auth_crypt`. And if the configured password encoder also requires an
salt this can be configured with `_auth_salt`.
#### Available Encoders
- SHA1
- SSHA1 (Salted SHA1)
- SHA256
- SSHA256 (salted SHA256)
- SHA384
- SSHA384 (salted SHA384)
- SHA512
- SSHA512 (salted SHA512)
### Restrictions
Operations on the database regarding user management can only be preformed by an administrator user.
### Support
The user authentication supports two kinds of users:
- administrators
- regular users
### User Management
User management can be done by directly using the `*SQLiteConn` or by SQL.
#### SQL
The following sql functions are available for user management:
| Function | Arguments | Description |
|----------|-----------|-------------|
| `authenticate` | username `string`, password `string` | Will authenticate an user, this is done by the connection; and should not be used manually. |
| `auth_user_add` | username `string`, password `string`, admin `int` | This function will add an user to the database.<br>if the database is not protected by user authentication it will enable it. Argument `admin` is an integer identifying if the added user should be an administrator. Only Administrators can add administrators. |
| `auth_user_change` | username `string`, password `string`, admin `int` | Function to modify an user. Users can change their own password, but only an administrator can change the administrator flag. |
| `authUserDelete` | username `string` | Delete an user from the database. Can only be used by an administrator. The current logged in administrator cannot be deleted. This is to make sure their is always an administrator remaining. |
These functions will return an integer:
- 0 (SQLITE_OK)
- 23 (SQLITE_AUTH) Failed to perform due to authentication or insufficient privileges
##### Examples
```sql
// Autheticate user
// Create Admin User
SELECT auth_user_add('admin2', 'admin2', 1);
// Change password for user
SELECT auth_user_change('user', 'userpassword', 0);
// Delete user
SELECT user_delete('user');
```
#### *SQLiteConn
The following functions are available for User authentication from the `*SQLiteConn`:
| Function | Description |
|----------|-------------|
| `Authenticate(username, password string) error` | Authenticate user |
| `AuthUserAdd(username, password string, admin bool) error` | Add user |
| `AuthUserChange(username, password string, admin bool) error` | Modify user |
| `AuthUserDelete(username string) error` | Delete user |
### Attached database
When using attached databases, SQLite will use the authentication from the `main` database for the attached database(s).
# Extensions
If you want your own extension to be listed here, or you want to add a reference to an extension; please submit an Issue for this.
## Spatialite
Spatialite is available as an extension to SQLite, and can be used in combination with this repository.
For an example, see [shaxbee/go-spatialite](https://github.com/shaxbee/go-spatialite).
## extension-functions.c from SQLite3 Contrib
extension-functions.c is available as an extension to SQLite, and provides the following functions:
- Math: acos, asin, atan, atn2, atan2, acosh, asinh, atanh, difference, degrees, radians, cos, sin, tan, cot, cosh, sinh, tanh, coth, exp, log, log10, power, sign, sqrt, square, ceil, floor, pi.
- String: replicate, charindex, leftstr, rightstr, ltrim, rtrim, trim, replace, reverse, proper, padl, padr, padc, strfilter.
- Aggregate: stdev, variance, mode, median, lower_quartile, upper_quartile
For an example, see [dinedal/go-sqlite3-extension-functions](https://github.com/dinedal/go-sqlite3-extension-functions).
# FAQ
- Getting insert error while query is opened.
> You can pass some arguments into the connection string, for example, a URI.
> See: [#39](https://github.com/mattn/go-sqlite3/issues/39)
- Do you want to cross compile? mingw on Linux or Mac?
> See: [#106](https://github.com/mattn/go-sqlite3/issues/106)
> See also: http://www.limitlessfx.com/cross-compile-golang-app-for-windows-from-linux.html
- Want to get time.Time with current locale
Use `_loc=auto` in SQLite3 filename schema like `file:foo.db?_loc=auto`.
- Can I use this in multiple routines concurrently?
Yes for readonly. But not for writable. See [#50](https://github.com/mattn/go-sqlite3/issues/50), [#51](https://github.com/mattn/go-sqlite3/issues/51), [#209](https://github.com/mattn/go-sqlite3/issues/209), [#274](https://github.com/mattn/go-sqlite3/issues/274).
- Why I'm getting `no such table` error?
Why is it racy if I use a `sql.Open("sqlite3", ":memory:")` database?
Each connection to `":memory:"` opens a brand new in-memory sql database, so if
the stdlib's sql engine happens to open another connection and you've only
specified `":memory:"`, that connection will see a brand new database. A
workaround is to use `"file::memory:?cache=shared"` (or `"file:foobar?mode=memory&cache=shared"`). Every
connection to this string will point to the same in-memory database.
Note that if the last database connection in the pool closes, the in-memory database is deleted. Make sure the [max idle connection limit](https://golang.org/pkg/database/sql/#DB.SetMaxIdleConns) is > 0, and the [connection lifetime](https://golang.org/pkg/database/sql/#DB.SetConnMaxLifetime) is infinite.
For more information see:
* [#204](https://github.com/mattn/go-sqlite3/issues/204)
* [#511](https://github.com/mattn/go-sqlite3/issues/511)
* https://www.sqlite.org/sharedcache.html#shared_cache_and_in_memory_databases
* https://www.sqlite.org/inmemorydb.html#sharedmemdb
- Reading from database with large amount of goroutines fails on OSX.
OS X limits OS-wide to not have more than 1000 files open simultaneously by default.
For more information, see [#289](https://github.com/mattn/go-sqlite3/issues/289)
- Trying to execute a `.` (dot) command throws an error.
Error: `Error: near ".": syntax error`
Dot command are part of SQLite3 CLI, not of this library.
You need to implement the feature or call the sqlite3 cli.
More information see [#305](https://github.com/mattn/go-sqlite3/issues/305).
- Error: `database is locked`
When you get a database is locked, please use the following options.
Add to DSN: `cache=shared`
Example:
```go
db, err := sql.Open("sqlite3", "file:locked.sqlite?cache=shared")
```
Next, please set the database connections of the SQL package to 1:
```go
db.SetMaxOpenConns(1)
```
For more information, see [#209](https://github.com/mattn/go-sqlite3/issues/209).
## Contributors
### Code Contributors
This project exists thanks to all the people who [[contribute](CONTRIBUTING.md)].
<a href="https://github.com/mattn/go-sqlite3/graphs/contributors"><img src="https://opencollective.com/mattn-go-sqlite3/contributors.svg?width=890&button=false" /></a>
### Financial Contributors
Become a financial contributor and help us sustain our community. [[Contribute here](https://opencollective.com/mattn-go-sqlite3/contribute)].
#### Individuals
<a href="https://opencollective.com/mattn-go-sqlite3"><img src="https://opencollective.com/mattn-go-sqlite3/individuals.svg?width=890"></a>
#### Organizations
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/mattn-go-sqlite3/contribute)]
<a href="https://opencollective.com/mattn-go-sqlite3/organization/0/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/0/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/1/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/1/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/2/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/2/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/3/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/3/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/4/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/4/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/5/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/5/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/6/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/6/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/7/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/7/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/8/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/8/avatar.svg"></a>
<a href="https://opencollective.com/mattn-go-sqlite3/organization/9/website"><img src="https://opencollective.com/mattn-go-sqlite3/organization/9/avatar.svg"></a>
# License
MIT: http://mattn.mit-license.org/2018
sqlite3-binding.c, sqlite3-binding.h, sqlite3ext.h
The -binding suffix was added to avoid build failures under gccgo.
In this repository, those files are an amalgamation of code that was copied from SQLite3. The license of that code is the same as the license of SQLite3.
# Author
Yasuhiro Matsumoto (a.k.a mattn)
G.J.R. Timmer

85
vendor/github.com/mattn/go-sqlite3/backup.go generated vendored Normal file
View File

@@ -0,0 +1,85 @@
// Copyright (C) 2019 Yasuhiro Matsumoto <mattn.jp@gmail.com>.
//
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package sqlite3
/*
#ifndef USE_LIBSQLITE3
#include "sqlite3-binding.h"
#else
#include <sqlite3.h>
#endif
#include <stdlib.h>
*/
import "C"
import (
"runtime"
"unsafe"
)
// SQLiteBackup implement interface of Backup.
type SQLiteBackup struct {
b *C.sqlite3_backup
}
// Backup make backup from src to dest.
func (destConn *SQLiteConn) Backup(dest string, srcConn *SQLiteConn, src string) (*SQLiteBackup, error) {
destptr := C.CString(dest)
defer C.free(unsafe.Pointer(destptr))
srcptr := C.CString(src)
defer C.free(unsafe.Pointer(srcptr))
if b := C.sqlite3_backup_init(destConn.db, destptr, srcConn.db, srcptr); b != nil {
bb := &SQLiteBackup{b: b}
runtime.SetFinalizer(bb, (*SQLiteBackup).Finish)
return bb, nil
}
return nil, destConn.lastError()
}
// Step to backs up for one step. Calls the underlying `sqlite3_backup_step`
// function. This function returns a boolean indicating if the backup is done
// and an error signalling any other error. Done is returned if the underlying
// C function returns SQLITE_DONE (Code 101)
func (b *SQLiteBackup) Step(p int) (bool, error) {
ret := C.sqlite3_backup_step(b.b, C.int(p))
if ret == C.SQLITE_DONE {
return true, nil
} else if ret != 0 && ret != C.SQLITE_LOCKED && ret != C.SQLITE_BUSY {
return false, Error{Code: ErrNo(ret)}
}
return false, nil
}
// Remaining return whether have the rest for backup.
func (b *SQLiteBackup) Remaining() int {
return int(C.sqlite3_backup_remaining(b.b))
}
// PageCount return count of pages.
func (b *SQLiteBackup) PageCount() int {
return int(C.sqlite3_backup_pagecount(b.b))
}
// Finish close backup.
func (b *SQLiteBackup) Finish() error {
return b.Close()
}
// Close close backup.
func (b *SQLiteBackup) Close() error {
ret := C.sqlite3_backup_finish(b.b)
// sqlite3_backup_finish() never fails, it just returns the
// error code from previous operations, so clean up before
// checking and returning an error
b.b = nil
runtime.SetFinalizer(b, nil)
if ret != 0 {
return Error{Code: ErrNo(ret)}
}
return nil
}

411
vendor/github.com/mattn/go-sqlite3/callback.go generated vendored Normal file
View File

@@ -0,0 +1,411 @@
// Copyright (C) 2019 Yasuhiro Matsumoto <mattn.jp@gmail.com>.
//
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package sqlite3
// You can't export a Go function to C and have definitions in the C
// preamble in the same file, so we have to have callbackTrampoline in
// its own file. Because we need a separate file anyway, the support
// code for SQLite custom functions is in here.
/*
#ifndef USE_LIBSQLITE3
#include "sqlite3-binding.h"
#else
#include <sqlite3.h>
#endif
#include <stdlib.h>
void _sqlite3_result_text(sqlite3_context* ctx, const char* s);
void _sqlite3_result_blob(sqlite3_context* ctx, const void* b, int l);
*/
import "C"
import (
"errors"
"fmt"
"math"
"reflect"
"sync"
"unsafe"
)
//export callbackTrampoline
func callbackTrampoline(ctx *C.sqlite3_context, argc int, argv **C.sqlite3_value) {
args := (*[(math.MaxInt32 - 1) / unsafe.Sizeof((*C.sqlite3_value)(nil))]*C.sqlite3_value)(unsafe.Pointer(argv))[:argc:argc]
fi := lookupHandle(C.sqlite3_user_data(ctx)).(*functionInfo)
fi.Call(ctx, args)
}
//export stepTrampoline
func stepTrampoline(ctx *C.sqlite3_context, argc C.int, argv **C.sqlite3_value) {
args := (*[(math.MaxInt32 - 1) / unsafe.Sizeof((*C.sqlite3_value)(nil))]*C.sqlite3_value)(unsafe.Pointer(argv))[:int(argc):int(argc)]
ai := lookupHandle(C.sqlite3_user_data(ctx)).(*aggInfo)
ai.Step(ctx, args)
}
//export doneTrampoline
func doneTrampoline(ctx *C.sqlite3_context) {
ai := lookupHandle(C.sqlite3_user_data(ctx)).(*aggInfo)
ai.Done(ctx)
}
//export compareTrampoline
func compareTrampoline(handlePtr unsafe.Pointer, la C.int, a *C.char, lb C.int, b *C.char) C.int {
cmp := lookupHandle(handlePtr).(func(string, string) int)
return C.int(cmp(C.GoStringN(a, la), C.GoStringN(b, lb)))
}
//export commitHookTrampoline
func commitHookTrampoline(handle unsafe.Pointer) int {
callback := lookupHandle(handle).(func() int)
return callback()
}
//export rollbackHookTrampoline
func rollbackHookTrampoline(handle unsafe.Pointer) {
callback := lookupHandle(handle).(func())
callback()
}
//export updateHookTrampoline
func updateHookTrampoline(handle unsafe.Pointer, op int, db *C.char, table *C.char, rowid int64) {
callback := lookupHandle(handle).(func(int, string, string, int64))
callback(op, C.GoString(db), C.GoString(table), rowid)
}
//export authorizerTrampoline
func authorizerTrampoline(handle unsafe.Pointer, op int, arg1 *C.char, arg2 *C.char, arg3 *C.char) int {
callback := lookupHandle(handle).(func(int, string, string, string) int)
return callback(op, C.GoString(arg1), C.GoString(arg2), C.GoString(arg3))
}
//export preUpdateHookTrampoline
func preUpdateHookTrampoline(handle unsafe.Pointer, dbHandle uintptr, op int, db *C.char, table *C.char, oldrowid int64, newrowid int64) {
hval := lookupHandleVal(handle)
data := SQLitePreUpdateData{
Conn: hval.db,
Op: op,
DatabaseName: C.GoString(db),
TableName: C.GoString(table),
OldRowID: oldrowid,
NewRowID: newrowid,
}
callback := hval.val.(func(SQLitePreUpdateData))
callback(data)
}
// Use handles to avoid passing Go pointers to C.
type handleVal struct {
db *SQLiteConn
val any
}
var handleLock sync.Mutex
var handleVals = make(map[unsafe.Pointer]handleVal)
func newHandle(db *SQLiteConn, v any) unsafe.Pointer {
handleLock.Lock()
defer handleLock.Unlock()
val := handleVal{db: db, val: v}
var p unsafe.Pointer = C.malloc(C.size_t(1))
if p == nil {
panic("can't allocate 'cgo-pointer hack index pointer': ptr == nil")
}
handleVals[p] = val
return p
}
func lookupHandleVal(handle unsafe.Pointer) handleVal {
handleLock.Lock()
defer handleLock.Unlock()
return handleVals[handle]
}
func lookupHandle(handle unsafe.Pointer) any {
return lookupHandleVal(handle).val
}
func deleteHandles(db *SQLiteConn) {
handleLock.Lock()
defer handleLock.Unlock()
for handle, val := range handleVals {
if val.db == db {
delete(handleVals, handle)
C.free(handle)
}
}
}
// This is only here so that tests can refer to it.
type callbackArgRaw C.sqlite3_value
type callbackArgConverter func(*C.sqlite3_value) (reflect.Value, error)
type callbackArgCast struct {
f callbackArgConverter
typ reflect.Type
}
func (c callbackArgCast) Run(v *C.sqlite3_value) (reflect.Value, error) {
val, err := c.f(v)
if err != nil {
return reflect.Value{}, err
}
if !val.Type().ConvertibleTo(c.typ) {
return reflect.Value{}, fmt.Errorf("cannot convert %s to %s", val.Type(), c.typ)
}
return val.Convert(c.typ), nil
}
func callbackArgInt64(v *C.sqlite3_value) (reflect.Value, error) {
if C.sqlite3_value_type(v) != C.SQLITE_INTEGER {
return reflect.Value{}, fmt.Errorf("argument must be an INTEGER")
}
return reflect.ValueOf(int64(C.sqlite3_value_int64(v))), nil
}
func callbackArgBool(v *C.sqlite3_value) (reflect.Value, error) {
if C.sqlite3_value_type(v) != C.SQLITE_INTEGER {
return reflect.Value{}, fmt.Errorf("argument must be an INTEGER")
}
i := int64(C.sqlite3_value_int64(v))
val := false
if i != 0 {
val = true
}
return reflect.ValueOf(val), nil
}
func callbackArgFloat64(v *C.sqlite3_value) (reflect.Value, error) {
if C.sqlite3_value_type(v) != C.SQLITE_FLOAT {
return reflect.Value{}, fmt.Errorf("argument must be a FLOAT")
}
return reflect.ValueOf(float64(C.sqlite3_value_double(v))), nil
}
func callbackArgBytes(v *C.sqlite3_value) (reflect.Value, error) {
switch C.sqlite3_value_type(v) {
case C.SQLITE_BLOB:
l := C.sqlite3_value_bytes(v)
p := C.sqlite3_value_blob(v)
return reflect.ValueOf(C.GoBytes(p, l)), nil
case C.SQLITE_TEXT:
l := C.sqlite3_value_bytes(v)
c := unsafe.Pointer(C.sqlite3_value_text(v))
return reflect.ValueOf(C.GoBytes(c, l)), nil
default:
return reflect.Value{}, fmt.Errorf("argument must be BLOB or TEXT")
}
}
func callbackArgString(v *C.sqlite3_value) (reflect.Value, error) {
switch C.sqlite3_value_type(v) {
case C.SQLITE_BLOB:
l := C.sqlite3_value_bytes(v)
p := (*C.char)(C.sqlite3_value_blob(v))
return reflect.ValueOf(C.GoStringN(p, l)), nil
case C.SQLITE_TEXT:
c := (*C.char)(unsafe.Pointer(C.sqlite3_value_text(v)))
return reflect.ValueOf(C.GoString(c)), nil
default:
return reflect.Value{}, fmt.Errorf("argument must be BLOB or TEXT")
}
}
func callbackArgGeneric(v *C.sqlite3_value) (reflect.Value, error) {
switch C.sqlite3_value_type(v) {
case C.SQLITE_INTEGER:
return callbackArgInt64(v)
case C.SQLITE_FLOAT:
return callbackArgFloat64(v)
case C.SQLITE_TEXT:
return callbackArgString(v)
case C.SQLITE_BLOB:
return callbackArgBytes(v)
case C.SQLITE_NULL:
// Interpret NULL as a nil byte slice.
var ret []byte
return reflect.ValueOf(ret), nil
default:
panic("unreachable")
}
}
func callbackArg(typ reflect.Type) (callbackArgConverter, error) {
switch typ.Kind() {
case reflect.Interface:
if typ.NumMethod() != 0 {
return nil, errors.New("the only supported interface type is any")
}
return callbackArgGeneric, nil
case reflect.Slice:
if typ.Elem().Kind() != reflect.Uint8 {
return nil, errors.New("the only supported slice type is []byte")
}
return callbackArgBytes, nil
case reflect.String:
return callbackArgString, nil
case reflect.Bool:
return callbackArgBool, nil
case reflect.Int64:
return callbackArgInt64, nil
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Uint:
c := callbackArgCast{callbackArgInt64, typ}
return c.Run, nil
case reflect.Float64:
return callbackArgFloat64, nil
case reflect.Float32:
c := callbackArgCast{callbackArgFloat64, typ}
return c.Run, nil
default:
return nil, fmt.Errorf("don't know how to convert to %s", typ)
}
}
func callbackConvertArgs(argv []*C.sqlite3_value, converters []callbackArgConverter, variadic callbackArgConverter) ([]reflect.Value, error) {
var args []reflect.Value
if len(argv) < len(converters) {
return nil, fmt.Errorf("function requires at least %d arguments", len(converters))
}
for i, arg := range argv[:len(converters)] {
v, err := converters[i](arg)
if err != nil {
return nil, err
}
args = append(args, v)
}
if variadic != nil {
for _, arg := range argv[len(converters):] {
v, err := variadic(arg)
if err != nil {
return nil, err
}
args = append(args, v)
}
}
return args, nil
}
type callbackRetConverter func(*C.sqlite3_context, reflect.Value) error
func callbackRetInteger(ctx *C.sqlite3_context, v reflect.Value) error {
switch v.Type().Kind() {
case reflect.Int64:
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Uint:
v = v.Convert(reflect.TypeOf(int64(0)))
case reflect.Bool:
b := v.Interface().(bool)
if b {
v = reflect.ValueOf(int64(1))
} else {
v = reflect.ValueOf(int64(0))
}
default:
return fmt.Errorf("cannot convert %s to INTEGER", v.Type())
}
C.sqlite3_result_int64(ctx, C.sqlite3_int64(v.Interface().(int64)))
return nil
}
func callbackRetFloat(ctx *C.sqlite3_context, v reflect.Value) error {
switch v.Type().Kind() {
case reflect.Float64:
case reflect.Float32:
v = v.Convert(reflect.TypeOf(float64(0)))
default:
return fmt.Errorf("cannot convert %s to FLOAT", v.Type())
}
C.sqlite3_result_double(ctx, C.double(v.Interface().(float64)))
return nil
}
func callbackRetBlob(ctx *C.sqlite3_context, v reflect.Value) error {
if v.Type().Kind() != reflect.Slice || v.Type().Elem().Kind() != reflect.Uint8 {
return fmt.Errorf("cannot convert %s to BLOB", v.Type())
}
i := v.Interface()
if i == nil || len(i.([]byte)) == 0 {
C.sqlite3_result_null(ctx)
} else {
bs := i.([]byte)
C._sqlite3_result_blob(ctx, unsafe.Pointer(&bs[0]), C.int(len(bs)))
}
return nil
}
func callbackRetText(ctx *C.sqlite3_context, v reflect.Value) error {
if v.Type().Kind() != reflect.String {
return fmt.Errorf("cannot convert %s to TEXT", v.Type())
}
C._sqlite3_result_text(ctx, C.CString(v.Interface().(string)))
return nil
}
func callbackRetNil(ctx *C.sqlite3_context, v reflect.Value) error {
return nil
}
func callbackRetGeneric(ctx *C.sqlite3_context, v reflect.Value) error {
if v.IsNil() {
C.sqlite3_result_null(ctx)
return nil
}
cb, err := callbackRet(v.Elem().Type())
if err != nil {
return err
}
return cb(ctx, v.Elem())
}
func callbackRet(typ reflect.Type) (callbackRetConverter, error) {
switch typ.Kind() {
case reflect.Interface:
errorInterface := reflect.TypeOf((*error)(nil)).Elem()
if typ.Implements(errorInterface) {
return callbackRetNil, nil
}
if typ.NumMethod() == 0 {
return callbackRetGeneric, nil
}
fallthrough
case reflect.Slice:
if typ.Elem().Kind() != reflect.Uint8 {
return nil, errors.New("the only supported slice type is []byte")
}
return callbackRetBlob, nil
case reflect.String:
return callbackRetText, nil
case reflect.Bool, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Uint:
return callbackRetInteger, nil
case reflect.Float32, reflect.Float64:
return callbackRetFloat, nil
default:
return nil, fmt.Errorf("don't know how to convert to %s", typ)
}
}
func callbackError(ctx *C.sqlite3_context, err error) {
cstr := C.CString(err.Error())
defer C.free(unsafe.Pointer(cstr))
C.sqlite3_result_error(ctx, cstr, C.int(-1))
}
// Test support code. Tests are not allowed to import "C", so we can't
// declare any functions that use C.sqlite3_value.
func callbackSyntheticForTests(v reflect.Value, err error) callbackArgConverter {
return func(*C.sqlite3_value) (reflect.Value, error) {
return v, err
}
}

299
vendor/github.com/mattn/go-sqlite3/convert.go generated vendored Normal file
View File

@@ -0,0 +1,299 @@
// Extracted from Go database/sql source code
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Type conversions for Scan.
package sqlite3
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"reflect"
"strconv"
"time"
)
var errNilPtr = errors.New("destination pointer is nil") // embedded in descriptive error
// convertAssign copies to dest the value in src, converting it if possible.
// An error is returned if the copy would result in loss of information.
// dest should be a pointer type.
func convertAssign(dest, src any) error {
// Common cases, without reflect.
switch s := src.(type) {
case string:
switch d := dest.(type) {
case *string:
if d == nil {
return errNilPtr
}
*d = s
return nil
case *[]byte:
if d == nil {
return errNilPtr
}
*d = []byte(s)
return nil
case *sql.RawBytes:
if d == nil {
return errNilPtr
}
*d = append((*d)[:0], s...)
return nil
}
case []byte:
switch d := dest.(type) {
case *string:
if d == nil {
return errNilPtr
}
*d = string(s)
return nil
case *any:
if d == nil {
return errNilPtr
}
*d = cloneBytes(s)
return nil
case *[]byte:
if d == nil {
return errNilPtr
}
*d = cloneBytes(s)
return nil
case *sql.RawBytes:
if d == nil {
return errNilPtr
}
*d = s
return nil
}
case time.Time:
switch d := dest.(type) {
case *time.Time:
*d = s
return nil
case *string:
*d = s.Format(time.RFC3339Nano)
return nil
case *[]byte:
if d == nil {
return errNilPtr
}
*d = []byte(s.Format(time.RFC3339Nano))
return nil
case *sql.RawBytes:
if d == nil {
return errNilPtr
}
*d = s.AppendFormat((*d)[:0], time.RFC3339Nano)
return nil
}
case nil:
switch d := dest.(type) {
case *any:
if d == nil {
return errNilPtr
}
*d = nil
return nil
case *[]byte:
if d == nil {
return errNilPtr
}
*d = nil
return nil
case *sql.RawBytes:
if d == nil {
return errNilPtr
}
*d = nil
return nil
}
}
var sv reflect.Value
switch d := dest.(type) {
case *string:
sv = reflect.ValueOf(src)
switch sv.Kind() {
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
*d = asString(src)
return nil
}
case *[]byte:
sv = reflect.ValueOf(src)
if b, ok := asBytes(nil, sv); ok {
*d = b
return nil
}
case *sql.RawBytes:
sv = reflect.ValueOf(src)
if b, ok := asBytes([]byte(*d)[:0], sv); ok {
*d = sql.RawBytes(b)
return nil
}
case *bool:
bv, err := driver.Bool.ConvertValue(src)
if err == nil {
*d = bv.(bool)
}
return err
case *any:
*d = src
return nil
}
if scanner, ok := dest.(sql.Scanner); ok {
return scanner.Scan(src)
}
dpv := reflect.ValueOf(dest)
if dpv.Kind() != reflect.Ptr {
return errors.New("destination not a pointer")
}
if dpv.IsNil() {
return errNilPtr
}
if !sv.IsValid() {
sv = reflect.ValueOf(src)
}
dv := reflect.Indirect(dpv)
if sv.IsValid() && sv.Type().AssignableTo(dv.Type()) {
switch b := src.(type) {
case []byte:
dv.Set(reflect.ValueOf(cloneBytes(b)))
default:
dv.Set(sv)
}
return nil
}
if dv.Kind() == sv.Kind() && sv.Type().ConvertibleTo(dv.Type()) {
dv.Set(sv.Convert(dv.Type()))
return nil
}
// The following conversions use a string value as an intermediate representation
// to convert between various numeric types.
//
// This also allows scanning into user defined types such as "type Int int64".
// For symmetry, also check for string destination types.
switch dv.Kind() {
case reflect.Ptr:
if src == nil {
dv.Set(reflect.Zero(dv.Type()))
return nil
}
dv.Set(reflect.New(dv.Type().Elem()))
return convertAssign(dv.Interface(), src)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
s := asString(src)
i64, err := strconv.ParseInt(s, 10, dv.Type().Bits())
if err != nil {
err = strconvErr(err)
return fmt.Errorf("converting driver.Value type %T (%q) to a %s: %v", src, s, dv.Kind(), err)
}
dv.SetInt(i64)
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
s := asString(src)
u64, err := strconv.ParseUint(s, 10, dv.Type().Bits())
if err != nil {
err = strconvErr(err)
return fmt.Errorf("converting driver.Value type %T (%q) to a %s: %v", src, s, dv.Kind(), err)
}
dv.SetUint(u64)
return nil
case reflect.Float32, reflect.Float64:
s := asString(src)
f64, err := strconv.ParseFloat(s, dv.Type().Bits())
if err != nil {
err = strconvErr(err)
return fmt.Errorf("converting driver.Value type %T (%q) to a %s: %v", src, s, dv.Kind(), err)
}
dv.SetFloat(f64)
return nil
case reflect.String:
switch v := src.(type) {
case string:
dv.SetString(v)
return nil
case []byte:
dv.SetString(string(v))
return nil
}
}
return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, dest)
}
func strconvErr(err error) error {
if ne, ok := err.(*strconv.NumError); ok {
return ne.Err
}
return err
}
func cloneBytes(b []byte) []byte {
if b == nil {
return nil
}
c := make([]byte, len(b))
copy(c, b)
return c
}
func asString(src any) string {
switch v := src.(type) {
case string:
return v
case []byte:
return string(v)
}
rv := reflect.ValueOf(src)
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(rv.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(rv.Uint(), 10)
case reflect.Float64:
return strconv.FormatFloat(rv.Float(), 'g', -1, 64)
case reflect.Float32:
return strconv.FormatFloat(rv.Float(), 'g', -1, 32)
case reflect.Bool:
return strconv.FormatBool(rv.Bool())
}
return fmt.Sprintf("%v", src)
}
func asBytes(buf []byte, rv reflect.Value) (b []byte, ok bool) {
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.AppendInt(buf, rv.Int(), 10), true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.AppendUint(buf, rv.Uint(), 10), true
case reflect.Float32:
return strconv.AppendFloat(buf, rv.Float(), 'g', -1, 32), true
case reflect.Float64:
return strconv.AppendFloat(buf, rv.Float(), 'g', -1, 64), true
case reflect.Bool:
return strconv.AppendBool(buf, rv.Bool()), true
case reflect.String:
s := rv.String()
return append(buf, s...), true
}
return
}

134
vendor/github.com/mattn/go-sqlite3/doc.go generated vendored Normal file
View File

@@ -0,0 +1,134 @@
/*
Package sqlite3 provides interface to SQLite3 databases.
This works as a driver for database/sql.
Installation
go get github.com/mattn/go-sqlite3
# Supported Types
Currently, go-sqlite3 supports the following data types.
+------------------------------+
|go | sqlite3 |
|----------|-------------------|
|nil | null |
|int | integer |
|int64 | integer |
|float64 | float |
|bool | integer |
|[]byte | blob |
|string | text |
|time.Time | timestamp/datetime|
+------------------------------+
# SQLite3 Extension
You can write your own extension module for sqlite3. For example, below is an
extension for a Regexp matcher operation.
#include <pcre.h>
#include <string.h>
#include <stdio.h>
#include <sqlite3ext.h>
SQLITE_EXTENSION_INIT1
static void regexp_func(sqlite3_context *context, int argc, sqlite3_value **argv) {
if (argc >= 2) {
const char *target = (const char *)sqlite3_value_text(argv[1]);
const char *pattern = (const char *)sqlite3_value_text(argv[0]);
const char* errstr = NULL;
int erroff = 0;
int vec[500];
int n, rc;
pcre* re = pcre_compile(pattern, 0, &errstr, &erroff, NULL);
rc = pcre_exec(re, NULL, target, strlen(target), 0, 0, vec, 500);
if (rc <= 0) {
sqlite3_result_error(context, errstr, 0);
return;
}
sqlite3_result_int(context, 1);
}
}
#ifdef _WIN32
__declspec(dllexport)
#endif
int sqlite3_extension_init(sqlite3 *db, char **errmsg,
const sqlite3_api_routines *api) {
SQLITE_EXTENSION_INIT2(api);
return sqlite3_create_function(db, "regexp", 2, SQLITE_UTF8,
(void*)db, regexp_func, NULL, NULL);
}
It needs to be built as a so/dll shared library. And you need to register
the extension module like below.
sql.Register("sqlite3_with_extensions",
&sqlite3.SQLiteDriver{
Extensions: []string{
"sqlite3_mod_regexp",
},
})
Then, you can use this extension.
rows, err := db.Query("select text from mytable where name regexp '^golang'")
# Connection Hook
You can hook and inject your code when the connection is established by setting
ConnectHook to get the SQLiteConn.
sql.Register("sqlite3_with_hook_example",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
sqlite3conn = append(sqlite3conn, conn)
return nil
},
})
You can also use database/sql.Conn.Raw (Go >= 1.13):
conn, err := db.Conn(context.Background())
// if err != nil { ... }
defer conn.Close()
err = conn.Raw(func (driverConn any) error {
sqliteConn := driverConn.(*sqlite3.SQLiteConn)
// ... use sqliteConn
})
// if err != nil { ... }
# Go SQlite3 Extensions
If you want to register Go functions as SQLite extension functions
you can make a custom driver by calling RegisterFunction from
ConnectHook.
regex = func(re, s string) (bool, error) {
return regexp.MatchString(re, s)
}
sql.Register("sqlite3_extended",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("regexp", regex, true)
},
})
You can then use the custom driver by passing its name to sql.Open.
var i int
conn, err := sql.Open("sqlite3_extended", "./foo.db")
if err != nil {
panic(err)
}
err = db.QueryRow(`SELECT regexp("foo.*", "seafood")`).Scan(&i)
if err != nil {
panic(err)
}
See the documentation of RegisterFunc for more details.
*/
package sqlite3

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