110 Commits

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

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

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

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

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

Testing
=======

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-17 15:55:22 +01:00
211 changed files with 29255 additions and 3345 deletions

View File

@@ -3,6 +3,10 @@ on:
push:
tags:
- v*
branches:
- master
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: nkanaev/yarr
@@ -17,6 +21,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@@ -29,6 +39,11 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=bleeding,enable=${{ github.ref_name == 'master' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
@@ -38,3 +53,4 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

View File

@@ -9,14 +9,14 @@ on:
jobs:
build_macos:
name: Build for MacOS
runs-on: macos-13
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: '^1.23'
go-version-file: 'go.mod'
- name: Build arm64 gui
uses: ./.github/actions/prepare
with:
@@ -44,14 +44,14 @@ jobs:
build_windows:
name: Build for Windows
runs-on: windows-2022
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: '^1.23'
go-version-file: 'go.mod'
- name: Build amd64 gui
uses: ./.github/actions/prepare
with:
@@ -67,15 +67,15 @@ jobs:
out: out/windows_arm64_gui/yarr.exe
build_multi_cli:
name: Build for Windows/MacOS/Linux CLI
name: Build for Windows/Linux (CLI)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: '^1.23'
go-version-file: 'go.mod'
- name: Setup Zig
uses: mlugg/setup-zig@v1
with:

View File

@@ -6,14 +6,17 @@ jobs:
test:
runs-on: ubuntu-latest
env:
YARR_POSTGRES_TEST_IMAGE: postgres:17-alpine
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: '^1.18'
go-version-file: 'go.mod'
- name: Run tests
run: make test

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@
*.db-wal
*.syso
versioninfo.rc
.DS_Store

View File

@@ -32,7 +32,7 @@ func opt(envVar, defaultValue string) string {
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
scanner := bufio.NewScanner(authfile)
for scanner.Scan() {
if scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
@@ -40,7 +40,6 @@ func parseAuthfile(authfile io.Reader) (username, password string, err error) {
}
username = parts[0]
password = parts[1]
break
}
return username, password, nil
}
@@ -74,7 +73,7 @@ func main() {
flag.Parse()
if ver {
fmt.Printf("v%s (%s)\n", Version, GitHash)
fmt.Printf("%s (%s)\n", Version, GitHash)
return
}

View File

@@ -1,6 +1,24 @@
# upcoming
- (new) initial PostgreSQL support
- (new) i18n: English, Chinese, French, German, Japanese, Portuguese, Russian, Spanish
- (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report)
- (fix) crash on empty article list with article is selected (thanks to @rksvc)
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
- (fix) parsing namespaced legacy RSS feeds (thanks to @f100024)
- (fix) marking feeds read in Fever API (thanks to @weskoop)
- (etc) systray improvements for macOS
# v2.6 (2025-11-24)
- (new) serve on unix socket (thanks to @rvighne)
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
- (fix) smooth scrolling on iOS (thanks to gatheraled)
- (fix) displaying youtube shorts in "Read Here" (thanks to @Dean-Corso for the report)
- (etc) theme-color support (thanks to @asimpson)
- (etc) cookie security measures (thanks to Tom Fitzhenry)
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
# v2.5 (2025-03-26)

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

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

1755
doc/fever-api.mhtml Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

View File

@@ -10,7 +10,7 @@ Name=yarr
Exec=$HOME/.local/bin/yarr -open
Icon=yarr
Type=Application
Categories=Internet;
Categories=Internet;Network;News;Feed;
END
if [[ ! -d "$HOME/.local/share/icons" ]]; then

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 335 KiB

View File

@@ -51,8 +51,9 @@ while [[ $# -gt 0 ]]; do
esac
done
# Replace dots with commas for version_comma
version_comma="${version//./,}"
# Strip leading 'v' and replace dots with commas for version_comma
version_num="${version#v}"
version_comma="${version_num//./,}"
# Use a here document for the template with ENDFILE delimiter
cat <<ENDFILE > "$outfile"

13
go.mod
View File

@@ -1,13 +1,16 @@
module github.com/nkanaev/yarr
go 1.23.0
toolchain go1.23.5
go 1.25.0
require (
fyne.io/systray v1.12.0
github.com/lib/pq v1.12.3
github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/net v0.37.0
golang.org/x/net v0.38.0
golang.org/x/sys v0.31.0
)
require golang.org/x/text v0.23.0 // indirect
require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
golang.org/x/text v0.23.0 // indirect
)

16
go.sum
View File

@@ -1,14 +1,14 @@
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=

View File

@@ -1,10 +1,11 @@
VERSION=2.5
VERSION=$(shell git describe --exact-match --tags HEAD 2>/dev/null || echo bleeding)
GITHASH=$(shell git rev-parse --short=8 HEAD)
GO_TAGS = sqlite_foreign_keys sqlite_json
GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
@@ -74,8 +75,10 @@ windows_amd64_gui: src/platform/versioninfo.rc
windows_arm64_gui: src/platform/versioninfo.rc
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
YARR_DB ?= local.db
serve:
go run $(GO_FLAGS) ./cmd/yarr -db local.db
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db "$(YARR_DB)"
test:
go test $(GO_FLAGS) ./...
@@ -86,4 +89,4 @@ test:
darwin_arm64 darwin_arm64_gui \
windows_amd64 windows_amd64_gui \
windows_arm64 windows_arm64_gui \
serve test
serve serve_postgres test

View File

@@ -7,6 +7,8 @@ The app is a single binary with an embedded database (SQLite).
![screenshot](etc/promo.png)
Subscribe: [releases](https://github.com/nkanaev/yarr/releases.atom) / [devlog](https://hachyderm.io/@nkanaev.rss) ([Mastodon](https://hachyderm.io/@nkanaev))
## usage
The latest prebuilt binaries for Linux/MacOS/Windows are available

View File

@@ -5,7 +5,6 @@ import (
"html/template"
"io"
"io/fs"
"io/ioutil"
"log"
"os"
)
@@ -37,7 +36,7 @@ func Template(path string) *template.Template {
}
defer svgfile.Close()
content, err := ioutil.ReadAll(svgfile)
content, err := io.ReadAll(svgfile)
// should never happen
if err != nil {
log.Fatal(err)
@@ -52,7 +51,7 @@ func Template(path string) *template.Template {
return tmpl
}
func Render(path string, writer io.Writer, data interface{}) {
func Render(path string, writer io.Writer, data any) {
tmpl := Template(path)
tmpl.Execute(writer, data)
}

View File

@@ -1,3 +1,5 @@
//go:build !debug
package assets
import "embed"

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

View File

@@ -8,6 +8,7 @@
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
<link rel="manifest" href="./manifest.json" />
<meta name="theme-color" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<script>
window.app = window.app || {}
@@ -23,49 +24,50 @@
<div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div>
<button class="toolbar-item"
<button class="toolbar-item ml-1"
:class="{active: filterSelected == 'unread'}"
:aria-pressed="filterSelected == 'unread'"
title="Unread"
:title="$t('unread')"
@click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span>
</button>
<button class="toolbar-item"
<button class="toolbar-item mx-1"
:class="{active: filterSelected == 'starred'}"
:aria-pressed="filterSelected == 'starred'"
title="Starred"
:title="$t('starred')"
@click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span>
</button>
<button class="toolbar-item"
<button class="toolbar-item mr-1"
:class="{active: filterSelected == ''}"
:aria-pressed="filterSelected == ''"
title="All"
:title="$t('all')"
@click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span>
</button>
<div class="flex-grow-1"></div>
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<button class="dropdown-item" @click="showSettings('create')">
<span class="icon mr-1">{% inline "plus.svg" %}</span>
New Feed
{{ $t('new_feed') }}
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="fetchAllFeeds()">
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds
{{ $t('refresh_feeds') }}
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Theme</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</header>
<div class="row text-center m-0">
<button class="btn btn-link col-4 px-0 rounded-0"
:class="'theme-'+t"
:title="t"
:aria-label="t"
:aria-pressed="theme.name == t"
@click.stop="theme.name = t"
@@ -76,25 +78,33 @@
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
<div class="row text-center m-0">
<button class="dropdown-item col-4 px-0" :aria-pressed="!refreshRate" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 10" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 30" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 60" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 120" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 240" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
<button class="dropdown-item col-4 px-0"
@click.stop="changeRefreshRate(-1)"
:disabled="!refreshRate">
<span class="icon">
{% inline "chevron-down.svg" %}
</span>
</button>
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
<button class="dropdown-item col-4 px-0"
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
<span class="icon">
{% inline "chevron-up.svg" %}
</span>
</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Show first</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('show_first') }}</header>
<div class="d-flex text-center">
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
<input type="file"
id="opml-import"
@@ -103,42 +113,55 @@
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
<span class="icon mr-1">{% inline "download.svg" %}</span>
Import
{{ $t('import') }}
</label>
</form>
<a class="dropdown-item" href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span>
Export
{{ $t('export') }}
</a>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="showSettings('shortcuts')">
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
Shortcuts
{{ $t('shortcuts') }}
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">A / あ / 文</header>
<div class="container">
<div class="row">
<button
v-for="lang in languages"
class="dropdown-item text-center col-3 px-0"
:aria-label="lang.name"
:title="lang.name"
:class="{active: language==lang.code}"
@click.stop="changeLanguage(lang.code)">
{{ lang.code }}
</button>
</div>
</div>
<div class="dropdown-divider" v-if="authenticated"></div>
<button class="dropdown-item" v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
{{ $t('log_out') }}
</button>
</dropdown>
</div>
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
<div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
<label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2">{% inline "layers.svg" %}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('all_unread') }}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">{{ $t('all_starred') }}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
<span class="counter text-right">{{ filteredTotalStats }}</span>
</div>
</label>
<div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1"
:class="{'d-none': filterSelected
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
&& !filteredFolderStats[folder.id]
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
:class="{'d-none': mustHideFolder(folder)}"
v-if="folder.id">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2"
@@ -152,10 +175,7 @@
</label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup"
:class="{'d-none': filterSelected
&& !(current.feed.id == feed.id)
&& !filteredFeedStats[feed.id]
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
:class="{'d-none': mustHideFeed(feed)}"
v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
@@ -175,7 +195,7 @@
</div>
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
<span class="icon loading mx-2"></span>
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
<span class="text-truncate cursor-default noselect">{{ $t('refreshing_progress', {count: loading.feeds}) }}</span>
</div>
</div>
<!-- item list -->
@@ -184,7 +204,7 @@
<div class="px-2 toolbar d-flex align-items-center">
<button class="toolbar-item mr-2 d-block d-md-none"
@click="feedSelected = null"
title="Show Feeds">
:title="$t('show_feeds')">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<div class="input-icon flex-grow-1">
@@ -195,7 +215,7 @@
<button class="toolbar-item ml-2"
@click="markItemsRead()"
v-if="filterSelected == 'unread'"
title="Mark All Read">
:title="$t('mark_all_read')">
<span class="icon">{% inline "check.svg" %}</span>
</button>
@@ -206,7 +226,7 @@
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
drop="right"
title="Feed Settings"
:title="$t('feed_settings')"
v-if="current.type == 'feed'">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
@@ -214,23 +234,23 @@
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
<span class="icon mr-1">{% inline "globe.svg" %}</span>
Website
{{ $t('website') }}
</a>
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "rss.svg" %}</span>
Feed Link
{{ $t('feed_link') }}
</a>
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
<button class="dropdown-item" @click="renameFeed(current.feed)">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename
{{ $t('rename') }}
</button>
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Change Link
{{ $t('change_link') }}
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Move to...</header>
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
<button class="dropdown-item"
v-if="folder.id != current.feed.folder_id"
v-for="folder in folders"
@@ -244,17 +264,17 @@
</button>
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
new folder
{{ $t('new_folder') }}
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
<span class="icon mr-1">{% inline "trash.svg" %}</span>
Delete
{{ $t('delete') }}
</button>
</dropdown>
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
title="Folder Settings"
:title="$t('folder_settings')"
drop="right"
v-if="current.type == 'folder'">
<template v-slot:button>
@@ -263,21 +283,21 @@
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
<button class="dropdown-item" @click="renameFolder(current.folder)">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename
{{ $t('rename') }}
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
<span class="icon mr-1">{% inline "trash.svg" %}</span>
Delete
{{ $t('delete') }}
</button>
</dropdown>
</div>
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id"
class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
<div class="selectgroup-label d-flex flex-column">
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<div style="line-height: 100%; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<transition name="indicator">
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
@@ -287,7 +307,7 @@
</small>
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
</div>
<div>{{ item.title || 'untitled' }}</div>
<div>{{ item.title || $t('untitled') }}</div>
</div>
</label>
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
@@ -301,24 +321,24 @@
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
<button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)"
title="Mark Starred">
:title="$t('mark_starred')">
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
</button>
<button class="toolbar-item"
title="Mark Unread"
:title="$t('mark_unread')"
@click="toggleItemRead(itemSelectedDetails)">
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
</button>
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
<template v-slot:button>
<span class="icon">{% inline "sliders.svg" %}</span>
</template>
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">monospace</button>
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
<div class="d-flex text-center">
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
@@ -328,30 +348,30 @@
<button class="toolbar-item"
:class="{active: itemSelectedReadability}"
@click="toggleReadability()"
title="Read Here">
:title="$t('read_here')">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button>
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="Open Link">
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
<span class="icon">{% inline "external-link.svg" %}</span>
</a>
<div class="flex-grow-1"></div>
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
<span class="icon">{% inline "chevron-right.svg" %}</span>
</button>
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
<span class="icon">{% inline "x.svg" %}</span>
</button>
</div>
<div v-if="itemSelectedDetails"
ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto"
class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
:style="{'font-size': theme.size + 'rem'}">
<div class="content-wrapper">
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
<div class="text-muted">
<div>
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
@@ -380,13 +400,13 @@
<span class="icon">{% inline "x.svg" %}</span>
</button>
<div v-if="settings=='create'">
<p class="cursor-default"><b>New Feed</b></p>
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
<label for="feed-url">URL</label>
<label for="feed-url">{{ $t('url') }}</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
<label for="feed-folder" class="mt-3 d-block">
Folder
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
{{ $t('folder') }}
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
</label>
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
<option value="">---</option>
@@ -394,8 +414,8 @@
</select>
<div class="mt-4" v-if="feedNewChoice.length">
<p class="mb-2">
Multiple feeds found. Choose one below:
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
{{ $t('multiple_feeds_found') }}
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
</p>
<label class="selectgroup" v-for="choice in feedNewChoice">
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
@@ -405,28 +425,29 @@
</div>
</label>
</div>
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">{{ $t('add') }}</button>
</form>
</div>
<div v-else-if="settings=='shortcuts'">
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
<table class="table table-borderless table-sm table-compact m-0">
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
<td>show unread / starred / all feeds</td></tr>
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
<td>{{ $t('kb_show_filters') }}</td></tr>
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
</tr>
</table>
</div>
@@ -434,7 +455,9 @@
</div>
<!-- external -->
<script src="./static/javascripts/vue.min.js"></script>
<script src="./static/javascripts/fluent.js"></script>
<!-- internal -->
<script src="./static/javascripts/i18n.js"></script>
<script src="./static/javascripts/api.js"></script>
<script src="./static/javascripts/app.js"></script>
<script src="./static/javascripts/key.js"></script>

View File

@@ -202,6 +202,8 @@ Vue.component('relative-time', {
},
})
Vue.use(i18n)
var vm = new Vue({
created: function() {
this.refreshStats()
@@ -211,6 +213,8 @@ var vm = new Vue({
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
this.updateMetaTheme(app.settings.theme_name)
this.$setLang(app.settings.language)
},
data: function() {
var s = app.settings
@@ -249,9 +253,37 @@ var vm = new Vue({
'font': s.theme_font,
'size': s.theme_size,
},
'themeColors': {
'night': '#0e0e0e',
'sepia': '#f4f0e5',
'light': '#fff',
},
'refreshRate': s.refresh_rate,
'authenticated': app.authenticated,
'feed_errors': {},
'refreshRateOptions': [
{ title: "0", value: 0 },
{ title: "10m", value: 10 },
{ title: "30m", value: 30 },
{ title: "1h", value: 60 },
{ title: "2h", value: 120 },
{ title: "4h", value: 240 },
{ title: "12h", value: 720 },
{ title: "24h", value: 1440 },
],
'language': s.language,
'languages': [
{code: 'en', name: 'English' },
{code: 'de', name: 'Deutsch'},
{code: 'es', name: 'Español'},
{code: 'fr', name: 'Français'},
{code: 'ja', name: '日本語'},
{code: 'pt', name: 'Português'},
{code: 'ru', name: 'Русский'},
{code: 'zh', name: '简体中文'},
]
}
},
computed: {
@@ -309,12 +341,17 @@ var vm = new Vue({
contentVideos: function() {
if (!this.itemSelectedDetails) return []
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video')
}
},
refreshRateTitle: function () {
const entry = this.refreshRateOptions.find(o => o.value === this.refreshRate)
return entry ? entry.title : '0'
},
},
watch: {
'theme': {
deep: true,
handler: function(theme) {
this.updateMetaTheme(theme.name)
document.body.classList.value = 'theme-' + theme.name
api.settings.update({
theme_name: theme.name,
@@ -339,14 +376,18 @@ var vm = new Vue({
},
'filterSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
this.itemSelected = null
this.items = []
this.itemsHasMore = true
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
this.computeStats()
},
'feedSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
this.itemSelected = null
this.items = []
this.itemsHasMore = true
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
},
'itemSelected': function(newVal, oldVal) {
@@ -390,6 +431,9 @@ var vm = new Vue({
},
},
methods: {
updateMetaTheme: function(theme) {
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
},
refreshStats: function(loopMode) {
return api.status().then(function(data) {
if (loopMode && !vm.itemSelected) vm.refreshItems()
@@ -516,7 +560,7 @@ var vm = new Vue({
})
},
moveFeedToNewFolder: function(feed) {
var title = prompt('Enter folder name:')
var title = prompt(this.$t('prompt_folder_name'))
if (!title) return
api.folders.create({'title': title}).then(function(folder) {
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
@@ -527,7 +571,7 @@ var vm = new Vue({
})
},
createNewFeedFolder: function() {
var title = prompt('Enter folder name:')
var title = prompt(this.$t('prompt_folder_name'))
if (!title) return
api.folders.create({'title': title}).then(function(result) {
vm.refreshFeeds().then(function() {
@@ -540,7 +584,7 @@ var vm = new Vue({
})
},
renameFolder: function(folder) {
var newTitle = prompt('Enter new title', folder.title)
var newTitle = prompt(this.$t('prompt_new_title'), folder.title)
if (newTitle) {
api.folders.update(folder.id, {title: newTitle}).then(function() {
folder.title = newTitle
@@ -551,7 +595,7 @@ var vm = new Vue({
}
},
deleteFolder: function(folder) {
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
if (confirm(this.$t('confirm_delete', {name: folder.title}))) {
api.folders.delete(folder.id).then(function() {
vm.feedSelected = null
vm.refreshStats()
@@ -560,7 +604,7 @@ var vm = new Vue({
}
},
updateFeedLink: function(feed) {
var newLink = prompt('Enter feed link', feed.feed_link)
var newLink = prompt(this.$t('prompt_feed_link'), feed.feed_link)
if (newLink) {
api.feeds.update(feed.id, {feed_link: newLink}).then(function() {
feed.feed_link = newLink
@@ -568,7 +612,7 @@ var vm = new Vue({
}
},
renameFeed: function(feed) {
var newTitle = prompt('Enter new title', feed.title)
var newTitle = prompt(this.$t('prompt_new_title'), feed.title)
if (newTitle) {
api.feeds.update(feed.id, {title: newTitle}).then(function() {
feed.title = newTitle
@@ -576,7 +620,7 @@ var vm = new Vue({
}
},
deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
if (confirm(this.$t('confirm_delete', {name: feed.title}))) {
api.feeds.delete(feed.id).then(function() {
vm.feedSelected = null
vm.refreshStats()
@@ -753,9 +797,18 @@ var vm = new Vue({
// navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) {
let vm = this
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
.map(function(r) { return r.value })
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)
@@ -778,6 +831,29 @@ var vm = new Vue({
if (target && scroll) scrollto(target, scroll)
})
},
changeRefreshRate: function(offset) {
const curIdx = this.refreshRateOptions.findIndex(o => o.value === this.refreshRate)
if (curIdx <= 0 && offset < 0) return
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
},
mustHideFolder: function (folder) {
return this.filterSelected
&& !(this.current.folder.id == folder.id || this.current.feed.folder_id == folder.id)
&& !this.filteredFolderStats[folder.id]
&& (!this.itemSelectedDetails || (this.feedsById[this.itemSelectedDetails.feed_id] || {}).folder_id != folder.id)
},
mustHideFeed: function (feed) {
return this.filterSelected
&& !(this.current.feed.id == feed.id)
&& !this.filteredFeedStats[feed.id]
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
},
changeLanguage(lang) {
this.$setLang(lang)
this.language = lang
api.settings.update({language: lang})
}
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,716 @@
(function (exports) {
const translations = {
"unread": {
"en": "Unread",
"de": "Ungelesene",
"fr": "Non lus",
"es": "No leídos",
"ja": "未読",
"pt": "Não lidos",
"zh": "未读",
"ru": "Непрочитанные"
},
"starred": {
"en": "Starred",
"de": "Markierte",
"fr": "Favoris",
"es": "Destacados",
"ja": "スター付き",
"pt": "Favoritos",
"zh": "星标",
"ru": "Избранные"
},
"all": {
"en": "All",
"de": "Alle",
"fr": "Tout",
"es": "Todo",
"ja": "すべて",
"pt": "Tudo",
"zh": "全部",
"ru": "Все"
},
"settings": {
"en": "Settings",
"de": "Einstellungen",
"fr": "Paramètres",
"es": "Ajustes",
"ja": "設定",
"pt": "Configurações",
"zh": "设置",
"ru": "Настройки"
},
"new_feed": {
"en": "New Feed",
"de": "Neuer Feed",
"fr": "Nouveau flux",
"es": "Nueva fuente",
"ja": "新規フィード",
"pt": "Novo feed",
"zh": "新建订阅",
"ru": "Новая лента"
},
"refresh_feeds": {
"en": "Refresh Feeds",
"de": "Feeds aktualisieren",
"fr": "Actualiser les flux",
"es": "Actualizar fuentes",
"ja": "フィードを更新",
"pt": "Atualizar feeds",
"zh": "刷新订阅",
"ru": "Обновить ленты"
},
"theme": {
"en": "Theme",
"de": "Design",
"fr": "Thème",
"es": "Tema",
"ja": "テーマ",
"pt": "Tema",
"zh": "主题",
"ru": "Тема"
},
"auto_refresh": {
"en": "Auto Refresh",
"de": "Automatisch aktualisieren",
"fr": "Actualisation automatique",
"es": "Actualización automática",
"ja": "自動更新",
"pt": "Atualização automática",
"zh": "自动刷新",
"ru": "Автообновление"
},
"show_first": {
"en": "Show first",
"de": "Zuerst anzeigen",
"fr": "Afficher d'abord",
"es": "Mostrar primero",
"ja": "表示順",
"pt": "Mostrar primeiro",
"zh": "优先显示",
"ru": "Сначала"
},
"new": {
"en": "New",
"de": "Neue",
"fr": "Récents",
"es": "Nuevos",
"ja": "新しい順",
"pt": "Novos",
"zh": "最新",
"ru": "Новые"
},
"old": {
"en": "Old",
"de": "Alte",
"fr": "Anciens",
"es": "Antiguos",
"ja": "古い順",
"pt": "Antigos",
"zh": "最旧",
"ru": "Старые"
},
"subscriptions": {
"en": "Subscriptions",
"de": "Abonnements",
"fr": "Abonnements",
"es": "Suscripciones",
"ja": "購読管理",
"pt": "Assinaturas",
"zh": "订阅管理",
"ru": "Подписки"
},
"import": {
"en": "Import",
"de": "Importieren",
"fr": "Importer",
"es": "Importar",
"ja": "インポート",
"pt": "Importar",
"zh": "导入",
"ru": "Импорт"
},
"export": {
"en": "Export",
"de": "Exportieren",
"fr": "Exporter",
"es": "Exportar",
"ja": "エクスポート",
"pt": "Exportar",
"zh": "导出",
"ru": "Экспорт"
},
"shortcuts": {
"en": "Shortcuts",
"de": "Tastenkürzel",
"fr": "Raccourcis",
"es": "Atajos",
"ja": "ショートカット",
"pt": "Atalhos",
"zh": "快捷键",
"ru": "Горячие клавиши"
},
"log_out": {
"en": "Log out",
"de": "Abmelden",
"fr": "Déconnexion",
"es": "Cerrar sesión",
"ja": "ログアウト",
"pt": "Sair",
"zh": "登出",
"ru": "Выйти"
},
"all_unread": {
"en": "All Unread",
"de": "Alle ungelesenen",
"fr": "Tous les non lus",
"es": "Todos los no leídos",
"ja": "すべての未読",
"pt": "Todos os não lidos",
"zh": "全部未读",
"ru": "Все непрочитанные"
},
"all_starred": {
"en": "All Starred",
"de": "Alle markierten",
"fr": "Tous les favoris",
"es": "Todos los destacados",
"ja": "すべてのスター付き",
"pt": "Todos os favoritos",
"zh": "全部星标",
"ru": "Все избранные"
},
"all_feeds": {
"en": "All Feeds",
"de": "Alle Feeds",
"fr": "Tous les flux",
"es": "Todas las fuentes",
"ja": "すべてのフィード",
"pt": "Todos os feeds",
"zh": "全部订阅",
"ru": "Все ленты"
},
"refreshing_progress": {
"en": "Refreshing ({ $count } left)",
"de": "Aktualisiere ({ $count } übrig)",
"fr": "Actualisation ({ $count } restantes)",
"es": "Actualizando ({ $count } restantes)",
"ja": "更新中(残り{ $count }",
"pt": "Atualizando ({ $count } restantes)",
"zh": "正在刷新(剩余{ $count }",
"ru": "Обновление: осталось { $count }"
},
"show_feeds": {
"en": "Show Feeds",
"de": "Feeds anzeigen",
"fr": "Afficher les flux",
"es": "Mostrar fuentes",
"ja": "フィードを表示",
"pt": "Mostrar feeds",
"zh": "显示订阅",
"ru": "Показать ленты"
},
"mark_all_read": {
"en": "Mark All Read",
"de": "Alle als gelesen markieren",
"fr": "Tout marquer comme lu",
"es": "Marcar todo como leído",
"ja": "すべて既読にする",
"pt": "Marcar todos como lidos",
"zh": "全部标记为已读",
"ru": "Отметить все как прочитанные"
},
"feed_settings": {
"en": "Feed Settings",
"de": "Feed-Einstellungen",
"fr": "Paramètres du flux",
"es": "Ajustes de fuente",
"ja": "フィード設定",
"pt": "Configurações do feed",
"zh": "订阅设置",
"ru": "Настройки ленты"
},
"folder_settings": {
"en": "Folder Settings",
"de": "Ordner-Einstellungen",
"fr": "Paramètres du dossier",
"es": "Ajustes de carpeta",
"ja": "フォルダ設定",
"pt": "Configurações da pasta",
"zh": "文件夹设置",
"ru": "Настройки папки"
},
"website": {
"en": "Website",
"de": "Webseite",
"fr": "Site web",
"es": "Sitio web",
"ja": "ウェブサイト",
"pt": "Site",
"zh": "网站",
"ru": "Сайт"
},
"feed_link": {
"en": "Feed Link",
"de": "Feed-Link",
"fr": "Lien du flux",
"es": "Enlace de la fuente",
"ja": "フィードリンク",
"pt": "Link do feed",
"zh": "订阅链接",
"ru": "Ссылка на ленту"
},
"rename": {
"en": "Rename",
"de": "Umbenennen",
"fr": "Renommer",
"es": "Renombrar",
"ja": "名前変更",
"pt": "Renomear",
"zh": "重命名",
"ru": "Переименовать"
},
"change_link": {
"en": "Change Link",
"de": "Link ändern",
"fr": "Changer le lien",
"es": "Cambiar enlace",
"ja": "リンク変更",
"pt": "Alterar link",
"zh": "修改链接",
"ru": "Изменить ссылку"
},
"move_to": {
"en": "Move to...",
"de": "Verschieben nach...",
"fr": "Déplacer vers...",
"es": "Mover a...",
"ja": "移動...",
"pt": "Mover para...",
"zh": "移动到...",
"ru": "Переместить в..."
},
"new_folder": {
"en": "new folder",
"de": "neuer Ordner",
"fr": "nouveau dossier",
"es": "nueva carpeta",
"ja": "新規フォルダ",
"pt": "nova pasta",
"zh": "新建文件夹",
"ru": "новая папка"
},
"delete": {
"en": "Delete",
"de": "Löschen",
"fr": "Supprimer",
"es": "Eliminar",
"ja": "削除",
"pt": "Excluir",
"zh": "删除",
"ru": "Удалить"
},
"mark_starred": {
"en": "Mark Starred",
"de": "Als markiert kennzeichnen",
"fr": "Marquer comme favori",
"es": "Marcar como destacado",
"ja": "スターを付ける",
"pt": "Marcar como favorito",
"zh": "标记星标",
"ru": "Пометить избранным"
},
"mark_unread": {
"en": "Mark Unread",
"de": "Als ungelesen kennzeichnen",
"fr": "Marquer comme non lu",
"es": "Marcar como no leído",
"ja": "未読にする",
"pt": "Marcar como não lido",
"zh": "标记未读",
"ru": "Пометить непрочитанным"
},
"appearance": {
"en": "Appearance",
"de": "Darstellung",
"fr": "Apparence",
"es": "Apariencia",
"ja": "表示設定",
"pt": "Aparência",
"zh": "外观",
"ru": "Внешний вид"
},
"read_here": {
"en": "Read Here",
"de": "Hier lesen",
"fr": "Lire ici",
"es": "Leer aquí",
"ja": "ここで読む",
"pt": "Ler aqui",
"zh": "在此阅读",
"ru": "Читать здесь"
},
"open_link": {
"en": "Open Link",
"de": "Link öffnen",
"fr": "Ouvrir le lien",
"es": "Abrir enlace",
"ja": "リンクを開く",
"pt": "Abrir link",
"zh": "打开链接",
"ru": "Открыть ссылку"
},
"previous_article": {
"en": "Previous Article",
"de": "Vorheriger Artikel",
"fr": "Article précédent",
"es": "Artículo anterior",
"ja": "前の記事",
"pt": "Artigo anterior",
"zh": "上一篇",
"ru": "Предыдущая статья"
},
"next_article": {
"en": "Next Article",
"de": "Nächster Artikel",
"fr": "Article suivant",
"es": "Artículo siguiente",
"ja": "次の記事",
"pt": "Próximo artigo",
"zh": "下一篇",
"ru": "Следующая статья"
},
"close_article": {
"en": "Close Article",
"de": "Artikel schließen",
"fr": "Fermer l'article",
"es": "Cerrar artículo",
"ja": "記事を閉じる",
"pt": "Fechar artigo",
"zh": "关闭文章",
"ru": "Закрыть статью"
},
"untitled": {
"en": "untitled",
"de": "unbenannt",
"fr": "sans titre",
"es": "sin título",
"ja": "無題",
"pt": "sem título",
"zh": "无标题",
"ru": "без названия"
},
"sans_serif": {
"en": "sans-serif",
"de": "serifenlos",
"fr": "sans empattement",
"es": "sans-serif",
"ja": "ゴシック体",
"pt": "sem serifa",
"zh": "无衬线",
"ru": "sans-serif"
},
"serif": {
"en": "serif",
"de": "Serife",
"fr": "empattement",
"es": "serifa",
"ja": "明朝体",
"pt": "com serifa",
"zh": "衬线",
"ru": "serif"
},
"monospace": {
"en": "monospace",
"de": "monospace",
"fr": "monospace",
"es": "monoespacio",
"ja": "等幅",
"pt": "monoespaçada",
"zh": "等宽",
"ru": "monospace"
},
"url": {
"en": "URL",
"de": "URL",
"fr": "URL",
"es": "URL",
"ja": "URL",
"pt": "URL",
"zh": "网址",
"ru": "URL"
},
"folder": {
"en": "Folder",
"de": "Ordner",
"fr": "Dossier",
"es": "Carpeta",
"ja": "フォルダ",
"pt": "Pasta",
"zh": "文件夹",
"ru": "Папка"
},
"add": {
"en": "Add",
"de": "Hinzufügen",
"fr": "Ajouter",
"es": "Añadir",
"ja": "追加",
"pt": "Adicionar",
"zh": "添加",
"ru": "Добавить"
},
"keyboard_shortcuts": {
"en": "Keyboard Shortcuts",
"de": "Tastenkürzel",
"fr": "Raccourcis clavier",
"es": "Atajos de teclado",
"ja": "キーボードショートカット",
"pt": "Atalhos do teclado",
"zh": "键盘快捷键",
"ru": "Горячие клавиши"
},
"multiple_feeds_found": {
"en": "Multiple feeds found. Choose one below:",
"de": "Mehrere Feeds gefunden. Bitte wählen Sie einen aus:",
"fr": "Plusieurs flux trouvés. Choisissez-en un ci-dessous :",
"es": "Múltiples fuentes encontradas. Elija una:",
"ja": "複数のフィードが見つかりました。以下から選択してください:",
"pt": "Múltiplos feeds encontrados. Escolha um abaixo:",
"zh": "找到多个订阅源,请选择一个:",
"ru": "Найдено несколько лент. Выберите одну:"
},
"cancel": {
"en": "cancel",
"de": "abbrechen",
"fr": "annuler",
"es": "cancelar",
"ja": "キャンセル",
"pt": "cancelar",
"zh": "取消",
"ru": "отмена"
},
"kb_show_filters": {
"en": "show unread / starred / all feeds",
"de": "ungelesene / markierte / alle Feeds anzeigen",
"fr": "afficher les flux non lus / favoris / tous",
"es": "mostrar fuentes no leídas / destacadas / todas",
"ja": "未読/スター付き/すべてのフィードを表示",
"pt": "mostrar feeds não lidos / favoritos / todos",
"zh": "显示未读/星标/全部订阅",
"ru": "показать непрочитанные / избранные / все ленты"
},
"kb_focus_search": {
"en": "focus the search bar",
"de": "Suchleiste fokussieren",
"fr": "focus sur la barre de recherche",
"es": "enfocar la barra de búsqueda",
"ja": "検索バーにフォーカス",
"pt": "focar na barra de pesquisa",
"zh": "聚焦搜索栏",
"ru": "фокус на строку поиска"
},
"kb_next_prev_article": {
"en": "next / prev article",
"de": "nächster / vorheriger Artikel",
"fr": "article suivant / précédent",
"es": "artículo siguiente / anterior",
"ja": "次の/前の記事",
"pt": "próximo / artigo anterior",
"zh": "下一篇/上一篇文章",
"ru": "следующая / предыдущая статья"
},
"kb_next_prev_feed": {
"en": "next / prev feed",
"de": "nächster / vorheriger Feed",
"fr": "flux suivant / précédent",
"es": "fuente siguiente / anterior",
"ja": "次の/前のフィード",
"pt": "próximo / feed anterior",
"zh": "下一个/上一个订阅",
"ru": "следующая / предыдущая лента"
},
"kb_close_article": {
"en": "close article",
"de": "Artikel schließen",
"fr": "fermer l'article",
"es": "cerrar artículo",
"ja": "記事を閉じる",
"pt": "fechar artigo",
"zh": "关闭文章",
"ru": "закрыть статью"
},
"kb_mark_all_read": {
"en": "mark all read",
"de": "alle als gelesen markieren",
"fr": "tout marquer comme lu",
"es": "marcar todo como leído",
"ja": "すべて既読にする",
"pt": "marcar todos como lidos",
"zh": "全部标记为已读",
"ru": "отметить все как прочитанные"
},
"kb_mark_read": {
"en": "mark read / unread",
"de": "als gelesen / ungelesen markieren",
"fr": "marquer comme lu / non lu",
"es": "marcar como leído / no leído",
"ja": "既読/未読を切り替え",
"pt": "marcar como lido / não lido",
"zh": "标记已读/未读",
"ru": "отметить как прочитанное / непрочитанное"
},
"kb_mark_starred": {
"en": "mark starred / unstarred",
"de": "als markiert / nicht markiert kennzeichnen",
"fr": "marquer comme favori / non favori",
"es": "marcar como destacado / no destacado",
"ja": "スターを付ける/外す",
"pt": "marcar como favorito / não favorito",
"zh": "标记星标/取消星标",
"ru": "пометить избранным / убрать из избранного"
},
"kb_open_link": {
"en": "open link",
"de": "Link öffnen",
"fr": "ouvrir le lien",
"es": "abrir enlace",
"ja": "リンクを開く",
"pt": "abrir link",
"zh": "打开链接",
"ru": "открыть ссылку"
},
"kb_read_here": {
"en": "read here",
"de": "hier lesen",
"fr": "lire ici",
"es": "leer aquí",
"ja": "ここで読む",
"pt": "ler aqui",
"zh": "在此阅读",
"ru": "читать здесь"
},
"kb_scroll_content": {
"en": "scroll content forward / backward",
"de": "Inhalt vorwärts / rückwärts scrollen",
"fr": "faire défiler le contenu avant / arrière",
"es": "desplazar contenido hacia adelante / atrás",
"ja": "コンテンツを前/後にスクロール",
"pt": "rolar conteúdo para frente / trás",
"zh": "向前/向后滚动内容",
"ru": "прокрутка вперед / назад"
},
"prompt_folder_name": {
"en": "Enter folder name:",
"de": "Ordnernamen eingeben:",
"fr": "Entrez le nom du dossier :",
"es": "Introduzca el nombre de la carpeta:",
"ja": "フォルダ名を入力してください:",
"pt": "Digite o nome da pasta:",
"zh": "请输入文件夹名称:",
"ru": "Введите имя папки:"
},
"prompt_new_title": {
"en": "Enter new title",
"de": "Neuen Titel eingeben",
"fr": "Entrez un nouveau titre",
"es": "Introduzca un nuevo título",
"ja": "新しいタイトルを入力してください",
"pt": "Digite o novo título",
"zh": "请输入新标题",
"ru": "Введите новый заголовок"
},
"prompt_feed_link": {
"en": "Enter feed link",
"de": "Feed-Link eingeben",
"fr": "Entrez le lien du flux",
"es": "Introduzca el enlace de la fuente",
"ja": "フィードリンクを入力してください",
"pt": "Digite o link do feed",
"zh": "请输入订阅链接",
"ru": "Введите ссылку на ленту"
},
"confirm_delete": {
"en": "Are you sure you want to delete { $name }?",
"de": "Möchten Sie { $name } wirklich löschen?",
"fr": "Voulez-vous vraiment supprimer { $name } ?",
"es": "¿Está seguro de que quiere eliminar { $name }?",
"ja": "{ $name }を削除してもよろしいですか?",
"pt": "Tem certeza que deseja excluir { $name }?",
"zh": "确定要删除{ $name }",
"ru": "Вы уверены, что хотите удалить { $name }?"
},
"alert_no_feeds": {
"en": "No feeds found at the given url.",
"de": "Keine Feeds unter der angegebenen URL gefunden.",
"fr": "Aucun flux trouvé à cette URL.",
"es": "No se encontraron fuentes en la URL proporcionada.",
"ja": "指定されたURLにフィードが見つかりませんでした。",
"pt": "Nenhum feed encontrado no URL fornecido.",
"zh": "在指定的网址未找到订阅源。",
"ru": "Лент по данному адресу не найдено."
},
"login": {
"en": "Login",
"de": "Anmelden",
"fr": "Connexion",
"es": "Iniciar sesión",
"ja": "ログイン",
"pt": "Entrar",
"zh": "登录",
"ru": "Вход"
},
"login_error": {
"en": "Invalid username or password",
"de": "Ungültiger Benutzername oder Passwort",
"fr": "Nom d'utilisateur ou mot de passe invalide",
"es": "Nombre de usuario o contraseña inválidos",
"ja": "ユーザー名またはパスワードが無効です",
"pt": "Nome de usuário ou senha inválidos",
"zh": "用户名或密码错误",
"ru": "Неверное имя пользователя или пароль"
},
"username": {
"en": "Username",
"de": "Benutzername",
"fr": "Nom d'utilisateur",
"es": "Nombre de usuario",
"ja": "ユーザー名",
"pt": "Nome de usuário",
"zh": "用户名",
"ru": "Имя пользователя"
},
"password": {
"en": "Password",
"de": "Passwort",
"fr": "Mot de passe",
"es": "Contraseña",
"ja": "パスワード",
"pt": "Senha",
"zh": "密码",
"ru": "Пароль"
},
};
function ftlFrom(lang) {
return Object.entries(translations)
.map(([key, langs]) => `${key} = ${langs[lang]}`)
.join('\n')
}
exports.i18n = {
install(Vue) {
let bundle = null
Vue.prototype.$setLang = function (lang) {
const ftl = ftlFrom(lang)
const resource = new FluentBundle.FluentResource(ftl)
bundle = new FluentBundle.FluentBundle(lang)
bundle.addResource(resource)
}
Vue.prototype.$t = function (code, args) {
if (!bundle) return
const msg = bundle.getMessage(code)
if (!msg || !msg.value) return
return bundle.formatPattern(msg.value, args)
}
}
}
})(window)

View File

@@ -60,6 +60,9 @@ var shortcutFunctions = {
scrollBackward: function() {
helperFunctions.scrollContent(-1)
},
closeItem: function () {
vm.itemSelected = null
},
showAll() {
vm.filterSelected = ''
},
@@ -85,6 +88,7 @@ var keybindings = {
"h": shortcutFunctions.previousFeed,
"f": shortcutFunctions.scrollForward,
"b": shortcutFunctions.scrollBackward,
"q": shortcutFunctions.closeItem,
"1": shortcutFunctions.showUnread,
"2": shortcutFunctions.showStarred,
"3": shortcutFunctions.showAll,
@@ -103,6 +107,7 @@ var codebindings = {
"KeyH": shortcutFunctions.previousFeed,
"KeyF": shortcutFunctions.scrollForward,
"KeyB": shortcutFunctions.scrollBackward,
"KeyQ": shortcutFunctions.closeItem,
"Digit1": shortcutFunctions.showUnread,
"Digit2": shortcutFunctions.showStarred,
"Digit3": shortcutFunctions.showAll,

View File

@@ -9,6 +9,7 @@
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
[v-cloak] { display: none }
form {
max-width: 300px;
margin: 0 auto;
@@ -23,21 +24,33 @@
</style>
</head>
<body class="theme-{% .settings.theme_name %}">
<form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt="">
{% if .error %}
<div class="text-danger text-center my-3">{% .error %}</div>
{% end %}
<div class="form-group">
<label for="username">Username</label>
<input name="username" class="form-control" id="username" autocomplete="off"
value="{% if .username %}{% .username %}{% end %}" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input name="password" class="form-control" id="password" type="password" required>
</div>
<button class="btn btn-block btn-default" type="submit">Login</button>
</form>
<div id="app" v-cloak>
<form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt="">
<div class="text-danger text-center my-3" v-if="hasError">{{ $t('login_error') }}</div>
<div class="form-group">
<label for="username">{{ $t('username') }}</label>
<input name="username" class="form-control" id="username" autocomplete="off"
value="{% if .username %}{% .username %}{% end %}" required autofocus>
</div>
<div class="form-group">
<label for="password">{{ $t('password') }}</label>
<input name="password" class="form-control" id="password" type="password" required>
</div>
<button class="btn btn-block btn-default" type="submit">{{ $t('login') }}</button>
</form>
</div>
<script src="./static/javascripts/vue.min.js"></script>
<script src="./static/javascripts/fluent.js"></script>
<script src="./static/javascripts/i18n.js"></script>
<script>
Vue.use(i18n)
new Vue({
data: { hasError: {% .hasError %} },
created: function () {
this.$setLang('{% .settings.language %}')
}
}).$mount('#app')
</script>
</body>
</html>

View File

@@ -4,9 +4,6 @@
html {
font-size: 15px !important;
}
body {
overscroll-behavior: none;
}
@@ -100,6 +97,10 @@ select.form-control:not([multiple]):not([size]) {
padding-right: 0;
}
.scroll-touch {
-webkit-overflow-scrolling: touch;
}
/* custom elements */
.font-serif {

View File

@@ -27,10 +27,16 @@ var (
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
unlikelyCandidatesRegexp = regexp.MustCompile(
`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`,
)
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
negativeRegexp = regexp.MustCompile(
`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`,
)
positiveRegexp = regexp.MustCompile(
`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`,
)
)
type nodeScores map[*html.Node]float32

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
@@ -146,7 +147,10 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
}
attrNames = append(attrNames, attribute.Key)
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
htmlAttrs = append(
htmlAttrs,
fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)),
)
}
extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
@@ -161,11 +165,25 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
func getExtraAttributes(tagName string) ([]string, []string) {
switch tagName {
case "a":
return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
return []string{
"rel",
"target",
"referrerpolicy",
}, []string{
`rel="noopener noreferrer"`,
`target="_blank"`,
`referrerpolicy="no-referrer"`,
}
case "video", "audio":
return []string{"controls"}, []string{"controls"}
case "iframe":
return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups"`, `loading="lazy"`}
return []string{
"sandbox",
"loading",
}, []string{
`sandbox="allow-scripts allow-same-origin allow-popups"`,
`loading="lazy"`,
}
case "img":
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
default:
@@ -208,10 +226,8 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
for element, attrs := range elements {
if tagName == element {
for _, attribute := range attributes {
for _, attr := range attrs {
if attr == attribute {
return true
}
if slices.Contains(attrs, attribute) {
return true
}
}
@@ -268,13 +284,7 @@ func isValidIframeSource(baseURL, src string) bool {
return true
}
for _, safeDomain := range whitelist {
if safeDomain == domain {
return true
}
}
return false
return slices.Contains(whitelist, domain)
}
func getTagAllowList() map[string][]string {
@@ -338,13 +348,7 @@ func getTagAllowList() map[string][]string {
}
func inList(needle string, haystack []string) bool {
for _, element := range haystack {
if element == needle {
return true
}
}
return false
return slices.Contains(haystack, needle)
}
func isBlockedTag(tagName string) bool {
@@ -354,13 +358,7 @@ func isBlockedTag(tagName string) bool {
"style",
}
for _, element := range blacklist {
if element == tagName {
return true
}
}
return false
return slices.Contains(blacklist, tagName)
}
/*

View File

@@ -2,6 +2,7 @@ package scraper
import (
"net/url"
"slices"
"strings"
"github.com/nkanaev/yarr/src/content/htmlutil"
@@ -22,10 +23,8 @@ func FindFeeds(body string, base string) map[string]string {
isFeedLink := func(n *html.Node) bool {
if n.Type == html.ElementNode && n.Data == "link" {
t := htmlutil.Attr(n, "type")
for _, tt := range linkTypes {
if tt == t {
return true
}
if slices.Contains(linkTypes, t) {
return true
}
}
return false

View File

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

View File

@@ -91,13 +91,22 @@ func ParseAtom(r io.Reader) (*Feed, error) {
mediaLinks := srcitem.mediaLinks()
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""), linkFromID)
link := firstNonEmpty(
srcitem.OrigLink,
srcitem.Links.First("alternate"),
srcitem.Links.First(""),
linkFromID,
)
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link,
Title: srcitem.Title.Text(),
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link,
Title: srcitem.Title.Text(),
Content: firstNonEmpty(
srcitem.Content.String(),
srcitem.Summary.String(),
srcitem.firstMediaDescription(),
),
MediaLinks: mediaLinks,
})
}

View File

@@ -80,7 +80,7 @@ func TestAtomHTMLTitle(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title type="html">say &lt;code&gt;what&lt;/code&gt;?</entry>
<entry><title type="html">say &lt;code&gt;what&lt;/code&gt;?</title></entry>
</feed>
`))
have := feed.Items[0].Title
@@ -96,12 +96,13 @@ 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>
<entry><title type="xhtml">say &lt;code&gt;what&lt;/code&gt;?</title></entry>
</feed>
`))
have := feed.Items[0].Title
want := "say what?"
if !reflect.DeepEqual(want, have) {
t.Log(feed)
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()

View File

@@ -15,7 +15,7 @@ import (
"golang.org/x/net/html/charset"
)
var UnknownFormat = errors.New("unknown feed format")
var ErrUnknownFormat = errors.New("unknown feed format")
type feedProbe struct {
feedType string
@@ -89,7 +89,7 @@ func ParseWithEncoding(r io.Reader, fallbackEncoding string) (*Feed, error) {
out := sniff(string(lookup))
if out.feedType == "" {
return nil, UnknownFormat
return nil, ErrUnknownFormat
}
if out.encoding == "" && fallbackEncoding != "" {

View File

@@ -40,7 +40,12 @@ func TestSniff(t *testing.T) {
want := testcase.want
have := sniff(testcase.input)
if want.encoding != have.encoding || want.feedType != have.feedType {
t.Errorf("Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v", testcase.input, want, have)
t.Errorf(
"Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v",
testcase.input,
want,
have,
)
}
}
}

View File

@@ -34,23 +34,6 @@ type mediaDescription struct {
Text string `xml:",chardata"`
}
func (m *media) firstMediaThumbnail() string {
for _, c := range m.MediaContents {
for _, t := range c.MediaThumbnails {
return t.URL
}
}
for _, t := range m.MediaThumbnails {
return t.URL
}
for _, g := range m.MediaGroups {
for _, t := range g.MediaThumbnails {
return t.URL
}
}
return ""
}
func (m *media) firstMediaDescription() string {
for _, d := range m.MediaDescriptions {
return plain2html(d.Text)
@@ -87,7 +70,10 @@ func (m *media) mediaLinks() []MediaLink {
} 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})
links = append(
links,
MediaLink{URL: url, Type: content.MediaMedium, Description: description},
)
} else {
if len(content.MediaThumbnails) > 0 {
links = append(links, MediaLink{

View File

@@ -42,8 +42,16 @@ func TestRDFFeed(t *testing.T) {
Title: "Mozilla Dot Org",
SiteURL: "http://www.mozilla.org",
Items: []Item{
{GUID: "http://www.mozilla.org/status/", URL: "http://www.mozilla.org/status/", Title: "New Status Updates"},
{GUID: "http://www.mozilla.org/bugs/", URL: "http://www.mozilla.org/bugs/", Title: "Bugzilla Reorganized"},
{
GUID: "http://www.mozilla.org/status/",
URL: "http://www.mozilla.org/status/",
Title: "New Status Updates",
},
{
GUID: "http://www.mozilla.org/bugs/",
URL: "http://www.mozilla.org/bugs/",
Title: "Bugzilla Reorganized",
},
},
}

View File

@@ -20,12 +20,12 @@ type rssFeed struct {
}
type rssItem struct {
GUID rssGuid `xml:"guid"`
Title string `xml:"title"`
GUID rssGuid `xml:"rss guid"`
Title string `xml:"rss title"`
Link string `xml:"rss link"`
Description string `xml:"rss description"`
PubDate string `xml:"pubDate"`
Enclosures []rssEnclosure `xml:"enclosure"`
PubDate string `xml:"rss pubDate"`
Enclosures []rssEnclosure `xml:"rss enclosure"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
@@ -48,12 +48,6 @@ type rssLink struct {
Rel string `xml:"rel,attr"`
}
type rssTitle struct {
XMLName xml.Name
Data string `xml:",chardata"`
Inner string `xml:",innerxml"`
}
type rssEnclosure struct {
URL string `xml:"url,attr"`
Type string `xml:"type,attr"`
@@ -63,9 +57,10 @@ type rssEnclosure struct {
func ParseRSS(r io.Reader) (*Feed, error) {
srcfeed := rssFeed{}
decoder := xmlDecoder(r)
decoder.DefaultSpace = "rss"
if err := decoder.Decode(&srcfeed); err != nil {
rawDecoder := xmlDecoder(r)
rawDecoder.DefaultSpace = "rss"
rssDecoder := xml.NewTokenDecoder(&rssTokenReader{Decoder: rawDecoder})
if err := rssDecoder.Decode(&srcfeed); err != nil {
return nil, err
}
@@ -78,13 +73,19 @@ func ParseRSS(r io.Reader) (*Feed, error) {
for _, e := range srcitem.Enclosures {
if strings.HasPrefix(e.Type, "audio/") {
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
}
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
break
}
}
for _, e := range srcitem.Enclosures {
if strings.HasPrefix(e.Type, "image/") {
mediaLinks = append(mediaLinks, MediaLink{URL: e.URL, Type: "image"})
}
}
permalink := ""
if srcitem.GUID.IsPermaLink == "true" {
@@ -92,11 +93,15 @@ func ParseRSS(r io.Reader) (*Feed, error) {
}
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title,
Content: firstNonEmpty(
srcitem.ContentEncoded,
srcitem.Description,
srcitem.firstMediaDescription(),
),
MediaLinks: mediaLinks,
})
}

View File

@@ -216,7 +216,7 @@ func TestRSSTitleHTMLTags(t *testing.T) {
`))
have := []string{feed.Items[0].Title, feed.Items[1].Title}
want := []string{"title in p", "very strong title"}
for i := 0; i < len(want); i++ {
for i := range want {
if want[i] != have[i] {
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
}
@@ -241,13 +241,42 @@ func TestRSSIsPermalink(t *testing.T) {
URL: "http://example.com/posts/1",
},
}
for i := 0; i < len(want); i++ {
for i := range want {
if !reflect.DeepEqual(want, have) {
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
}
}
}
// https://github.com/nkanaev/yarr/issues/284
func TestRSSEnclosureImage(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Post with image</title>
<link>http://example.com/post/1</link>
<enclosure url="http://example.com/photo.jpg" type="image/jpeg" length="123456"/>
</item>
</channel>
</rss>
`))
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatalf("Expected 1 media link, got %d: %#v", len(feed.Items[0].MediaLinks), feed.Items[0].MediaLinks)
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "http://example.com/photo.jpg",
Type: "image",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
}
}
func TestRSSMultipleMedia(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
@@ -274,9 +303,21 @@ func TestRSSMultipleMedia(t *testing.T) {
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"},
{
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",
},
},
},
}
@@ -286,3 +327,68 @@ func TestRSSMultipleMedia(t *testing.T) {
t.Fatal("invalid rss")
}
}
// When both RSS <link> and Atom <atom:link> elements are present in an item,
// the RSS link must not be lost. The <link> tag is namespace-qualified as
// `rss link` to disambiguate — see commit ee2a825, found in:
// https://rss.nytimes.com/services/xml/rss/nyt/Arts.xml
func TestRSSItemLinkWithAtomLinkPresent(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Example</title>
<item>
<title>Article</title>
<link>http://example.com/article/1</link>
<atom:link href="http://example.com/article/1/atom" rel="alternate" type="text/html"/>
</item>
</channel>
</rss>
`))
want := &Feed{
Title: "Example",
Items: []Item{
{
GUID: "http://example.com/article/1",
URL: "http://example.com/article/1",
Title: "Article",
},
},
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("RSS link lost when atom:link is present\nwant: %#v\nhave: %#v", want, have)
}
}
// Feeds that declare a default namespace on the root <rss> element (e.g. the
// legacy Userland namespace) must still parse — see sud.ua/rss/rss_news_uk.xml.
func TestRSSDefaultNamespace(t *testing.T) {
have, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<rss xmlns="http://backend.userland.com/rss2" version="2.0">
<channel>
<title>Feed</title>
<item>
<title>Title 1</title>
<link>https://example.com/news/1</link>
</item>
</channel>
</rss>
`))
want := &Feed{
Title: "Feed",
Items: []Item{
{
GUID: "https://example.com/news/1",
URL: "https://example.com/news/1",
Title: "Title 1",
},
},
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("default-namespaced rss not parsed \nwant: %#v\nhave: %#v", want, have)
// t.Logf("have: %#v", have)
// t.Fatal("default-namespaced rss not parsed")
}
}

View File

@@ -42,6 +42,59 @@ func xmlDecoder(r io.Reader) *xml.Decoder {
return decoder
}
// XML token reader that strips the default namespace.
// It's primary purpose is to support namespaced legacy UserLand RSS feeds.
// NOTE: token readers cannot populate ",innerxml"-tagged struct fields,
// see https://github.com/golang/go/issues/39645
type rssTokenReader struct {
Decoder *xml.Decoder
defaultNS string
}
func (r *rssTokenReader) Token() (xml.Token, error) {
tok, err := r.Decoder.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
// extract default namespace: <rss xmlns="<defaultNS>">
if t.Name.Local == "rss" {
for _, attr := range t.Attr {
if attr.Name.Space == "" && attr.Name.Local == "xmlns" && attr.Value != "" {
r.defaultNS = attr.Value
break
}
}
}
if r.defaultNS != "" {
// Rewrite element namespace
if t.Name.Space == r.defaultNS {
t.Name.Space = r.Decoder.DefaultSpace
}
// Rewrite attribute namespaces
attrs := t.Attr[:0]
for _, a := range t.Attr {
if a.Name.Space == r.defaultNS {
a.Name.Space = r.Decoder.DefaultSpace
}
attrs = append(attrs, a)
}
t.Attr = attrs
}
return t, nil
case xml.EndElement:
if r.defaultNS != "" && t.Name.Space == r.defaultNS {
t.Name.Space = r.Decoder.DefaultSpace
}
return t, nil
default:
return tok, nil
}
}
type safexmlreader struct {
reader *bufio.Reader
buffer *bytes.Buffer

View File

@@ -46,7 +46,7 @@ func TestSafeXMLReaderPartial1(t *testing.T) {
f = NewSafeXMLReader(f)
buf := make([]byte, 1)
for i := 0; i < len(want); i++ {
for i := range want {
n, err := f.Read(buf)
if err != nil {
t.Fatal(err)

View File

@@ -3,13 +3,13 @@
package platform
import (
"fyne.io/systray"
"github.com/nkanaev/yarr/src/server"
"github.com/nkanaev/yarr/src/systray"
)
func Start(s *server.Server) {
systrayOnReady := func() {
systray.SetIcon(Icon)
systray.SetTemplateIcon(Icon, Icon)
systray.SetTooltip("yarr")
menuOpen := systray.AddMenuItem("Open", "")

View File

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

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -4,5 +4,5 @@ package platform
import _ "embed"
//go:embed icon.png
//go:embed icon_mac.png
var Icon []byte

BIN
src/platform/icon_mac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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

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

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

View File

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

View File

@@ -14,11 +14,7 @@ type Middleware struct {
Password string
BasePath string
Public []string
DB *storage.Storage
}
func unsafeMethod(method string) bool {
return method == "POST" || method == "PUT" || method == "DELETE"
DB storage.Storage
}
func (m *Middleware) Handler(c *router.Context) {
@@ -48,15 +44,16 @@ func (m *Middleware) Handler(c *router.Context) {
c.Redirect(rootUrl)
return
} else {
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
"username": username,
"error": "Invalid username/password",
"settings": m.DB.GetSettings(),
"hasError": true,
"settings": m.DB.GetSettings().Map(),
})
return
}
}
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
"settings": m.DB.GetSettings(),
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
"hasError": false,
"settings": m.DB.GetSettings().Map(),
})
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/nkanaev/yarr/src/server/auth"
"github.com/nkanaev/yarr/src/server/router"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
type FeverGroup struct {
@@ -53,20 +54,17 @@ type FeverFavicon struct {
Data string `json:"data"`
}
func writeFeverJSON(c *router.Context, data map[string]interface{}, lastRefreshed int64) {
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
data["api_version"] = 3
data["auth"] = 1
// TODO: remove duplicates
data["last_refreshed_on_time"] = lastRefreshed
c.JSON(http.StatusOK, data)
}
func getLastRefreshedOnTime(httpStates map[int64]storage.HTTPState) int64 {
if len(httpStates) == 0 {
return 0
}
func getLastRefreshedOnTime(feedStates []model.FeedState) int64 {
var lastRefreshed int64
for _, state := range httpStates {
for _, state := range feedStates {
if state.LastRefreshed.Unix() > lastRefreshed {
lastRefreshed = state.LastRefreshed.Unix()
}
@@ -78,7 +76,7 @@ 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)))
md5HashValue := md5.Sum(fmt.Appendf(nil, "%s:%s", s.Username, s.Password))
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
return false
@@ -97,7 +95,7 @@ func formHasValue(values url.Values, value string) bool {
func (s *Server) handleFever(c *router.Context) {
c.Req.ParseForm()
if !s.feverAuth(c) {
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"api_version": 3,
"auth": 0,
"last_refreshed_on_time": 0,
@@ -123,10 +121,11 @@ func (s *Server) handleFever(c *router.Context) {
case formHasValue(c.Req.Form, "mark"):
s.feverMarkHandler(c)
default:
c.JSON(http.StatusOK, map[string]interface{}{
states, _ := s.db.ListFeedStates()
c.JSON(http.StatusOK, map[string]any{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
"last_refreshed_on_time": getLastRefreshedOnTime(states),
})
}
}
@@ -142,7 +141,7 @@ func joinInts(values []int64) string {
return result.String()
}
func feedGroups(db *storage.Storage) []*FeverFeedsGroup {
func feedGroups(db storage.Storage) []*FeverFeedsGroup {
feeds := db.ListFeeds()
groupFeeds := make(map[int64][]int64)
@@ -168,20 +167,25 @@ func (s *Server) feverGroupsHandler(c *router.Context) {
for i, folder := range folders {
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
}
writeFeverJSON(c, map[string]interface{}{
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"groups": groups,
"feeds_groups": feedGroups(s.db),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverFeedsHandler(c *router.Context) {
feeds := s.db.ListFeeds()
httpStates := s.db.ListHTTPStates()
states, _ := s.db.ListFeedStates()
statesMap := make(map[int64]model.FeedState)
for _, state := range states {
statesMap[state.FeedID] = state
}
feverFeeds := make([]*FeverFeed, len(feeds))
for i, feed := range feeds {
var lastUpdated int64
if state, ok := httpStates[feed.Id]; ok {
if state, ok := statesMap[feed.Id]; ok {
lastUpdated = state.LastRefreshed.Unix()
}
feverFeeds[i] = &FeverFeed{
@@ -194,10 +198,10 @@ func (s *Server) feverFeedsHandler(c *router.Context) {
LastUpdated: lastUpdated,
}
}
writeFeverJSON(c, map[string]interface{}{
writeFeverJSON(c, map[string]any{
"feeds": feverFeeds,
"feeds_groups": feedGroups(s.db),
}, getLastRefreshedOnTime(httpStates))
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverFaviconsHandler(c *router.Context) {
@@ -216,9 +220,10 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
}
writeFeverJSON(c, map[string]interface{}{
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"favicons": favicons,
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}, getLastRefreshedOnTime(states))
}
// for memory pressure reasons, we only return a limited number of items
@@ -226,7 +231,7 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
const listLimit = 50
func (s *Server) feverItemsHandler(c *router.Context) {
filter := storage.ItemFilter{}
filter := model.ItemFilter{}
query := c.Req.URL.Query()
switch {
@@ -258,11 +263,11 @@ func (s *Server) feverItemsHandler(c *router.Context) {
time := date.Unix()
isSaved := 0
if item.Status == storage.STARRED {
if item.Status == model.STARRED {
isSaved = 1
}
isRead := 0
if item.Status == storage.READ {
if item.Status == model.READ {
isRead = 1
}
feverItems[i] = FeverItem{
@@ -278,25 +283,27 @@ func (s *Server) feverItemsHandler(c *router.Context) {
}
}
totalItems := s.db.CountItems(storage.ItemFilter{})
totalItems := s.db.CountItems()
writeFeverJSON(c, map[string]interface{}{
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"items": feverItems,
"total_items": totalItems,
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverLinksHandler(c *router.Context) {
writeFeverJSON(c, map[string]interface{}{
"links": make([]interface{}, 0),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"links": make([]any, 0),
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
status := storage.UNREAD
status := model.UNREAD
itemIds := make([]int64, 0)
itemFilter := storage.ItemFilter{
itemFilter := model.ItemFilter{
Status: &status,
}
for {
@@ -309,16 +316,17 @@ func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
}
itemFilter.After = &items[len(items)-1].Id
}
writeFeverJSON(c, map[string]interface{}{
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"unread_item_ids": joinInts(itemIds),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
status := storage.STARRED
status := model.STARRED
itemIds := make([]int64, 0)
itemFilter := storage.ItemFilter{
itemFilter := model.ItemFilter{
Status: &status,
}
for {
@@ -331,9 +339,10 @@ func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
}
itemFilter.After = &items[len(items)-1].Id
}
writeFeverJSON(c, map[string]interface{}{
states, _ := s.db.ListFeedStates()
writeFeverJSON(c, map[string]any{
"saved_item_ids": joinInts(itemIds),
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
}, getLastRefreshedOnTime(states))
}
func (s *Server) feverMarkHandler(c *router.Context) {
@@ -345,16 +354,16 @@ func (s *Server) feverMarkHandler(c *router.Context) {
switch c.Req.Form.Get("mark") {
case "item":
var status storage.ItemStatus
var status model.ItemStatus
switch c.Req.Form.Get("as") {
case "read":
status = storage.READ
status = model.READ
case "unread":
status = storage.UNREAD
status = model.UNREAD
case "saved":
status = storage.STARRED
status = model.STARRED
case "unsaved":
status = storage.READ
status = model.READ
default:
c.Out.WriteHeader(http.StatusBadRequest)
return
@@ -364,10 +373,10 @@ func (s *Server) feverMarkHandler(c *router.Context) {
if c.Req.Form.Get("as") != "read" {
c.Out.WriteHeader(http.StatusBadRequest)
}
markFilter := storage.MarkFilter{FeedID: &id}
markFilter := model.MarkFilter{FeedID: &id}
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
if x > 0 {
before := time.Unix(x, 0)
before := time.Unix(x, 0).UTC()
markFilter.Before = &before
}
s.db.MarkItemsRead(markFilter)
@@ -375,10 +384,13 @@ func (s *Server) feverMarkHandler(c *router.Context) {
if c.Req.Form.Get("as") != "read" {
c.Out.WriteHeader(http.StatusBadRequest)
}
markFilter := storage.MarkFilter{FolderID: &id}
markFilter := model.MarkFilter{}
if id > 0 {
markFilter.FolderID = &id
}
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
if x > 0 {
before := time.Unix(x, 0)
before := time.Unix(x, 0).UTC()
markFilter.Before = &before
}
s.db.MarkItemsRead(markFilter)
@@ -386,7 +398,7 @@ func (s *Server) feverMarkHandler(c *router.Context) {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"api_version": 3,
"auth": 1,
})

View File

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

View File

@@ -78,7 +78,11 @@ func TestParseFallback(t *testing.T) {
Folders: []Folder{{
Title: "foldertitle",
Feeds: []Feed{
{Title: "feedtext", FeedUrl: "https://example.com/feed.xml", SiteUrl: "https://example.com"},
{
Title: "feedtext",
FeedUrl: "https://example.com/feed.xml",
SiteUrl: "https://example.com",
},
},
}},
}

View File

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

View File

@@ -20,7 +20,7 @@ import (
"github.com/nkanaev/yarr/src/server/gzip"
"github.com/nkanaev/yarr/src/server/opml"
"github.com/nkanaev/yarr/src/server/router"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
"github.com/nkanaev/yarr/src/worker"
)
@@ -34,7 +34,7 @@ func (s *Server) handler() http.Handler {
BasePath: s.BasePath,
Username: s.Username,
Password: s.Password,
Public: []string{"/static", "/fever"},
Public: []string{"/static", "/fever", "/manifest.json"},
DB: s.db,
}
r.Use(a.Handler)
@@ -64,8 +64,8 @@ func (s *Server) handler() http.Handler {
}
func (s *Server) handleIndex(c *router.Context) {
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]interface{}{
"settings": s.db.GetSettings(),
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
"settings": s.db.GetSettings().Map(),
"authenticated": s.Username != "" && s.Password != "",
})
}
@@ -77,18 +77,19 @@ func (s *Server) handleStatic(c *router.Context) {
c.Out.WriteHeader(http.StatusNotFound)
return
}
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).ServeHTTP(c.Out, c.Req)
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).
ServeHTTP(c.Out, c.Req)
}
func (s *Server) handleManifest(c *router.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"name": "yarr!",
"short_name": "yarr",
"description": "yet another rss reader",
"display": "standalone",
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
"icons": []map[string]interface{}{
"icons": []map[string]any{
{
"src": s.BasePath + "/static/graphicarts/favicon.png",
"sizes": "64x64",
@@ -99,7 +100,7 @@ func (s *Server) handleManifest(c *router.Context) {
}
func (s *Server) handleStatus(c *router.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"running": s.worker.FeedsPending(),
"stats": s.db.FeedStats(),
})
@@ -140,12 +141,10 @@ func (s *Server) handleFolder(c *router.Context) {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
if body.Title != nil {
s.db.RenameFolder(id, *body.Title)
}
if body.IsExpanded != nil {
s.db.ToggleFolderExpanded(id, *body.IsExpanded)
}
s.db.UpdateFolder(id, model.UpdateFolderParams{
Title: body.Title,
IsExpanded: body.IsExpanded,
})
c.Out.WriteHeader(http.StatusOK)
} else if c.Req.Method == "DELETE" {
s.db.DeleteFolder(id)
@@ -163,7 +162,15 @@ func (s *Server) handleFeedRefresh(c *router.Context) {
}
func (s *Server) handleFeedErrors(c *router.Context) {
errors := s.db.GetFeedErrors()
errors := make(map[int64]string)
states, err := s.db.ListFeedStates()
if err == nil {
for _, state := range states {
if state.LastError != "" {
errors[state.FeedID] = state.LastError
}
}
}
c.JSON(http.StatusOK, errors)
}
@@ -236,24 +243,24 @@ func (s *Server) handleFeedList(c *router.Context) {
log.Printf("Faild to discover feed for %s: %s", form.Url, err)
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
case len(result.Sources) > 0:
c.JSON(http.StatusOK, map[string]interface{}{"status": "multiple", "choice": result.Sources})
case result.Feed != nil:
feed := s.db.CreateFeed(
result.Feed.Title,
"",
result.Feed.SiteURL,
result.FeedLink,
form.FolderID,
c.JSON(
http.StatusOK,
map[string]any{"status": "multiple", "choice": result.Sources},
)
case result.Feed != nil:
feed := s.db.CreateFeed(model.CreateFeedParams{
Title: result.Feed.Title,
Link: result.Feed.SiteURL,
FeedLink: result.FeedLink,
FolderID: form.FolderID,
})
items := worker.ConvertItems(result.Feed.Items, *feed)
if len(items) > 0 {
s.db.CreateItems(items)
s.db.SetFeedSize(feed.Id, len(items))
s.db.SyncSearch()
}
s.worker.FindFeedFavicon(*feed)
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"status": "success",
"feed": feed,
})
@@ -275,30 +282,34 @@ func (s *Server) handleFeed(c *router.Context) {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
body := make(map[string]interface{})
body := make(map[string]any)
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
log.Print(err)
c.Out.WriteHeader(http.StatusBadRequest)
return
}
params := model.UpdateFeedParams{}
if title, ok := body["title"]; ok {
if reflect.TypeOf(title).Kind() == reflect.String {
s.db.RenameFeed(id, title.(string))
t := title.(string)
params.Title = &t
}
}
if f_id, ok := body["folder_id"]; ok {
if f_id == nil {
s.db.UpdateFeedFolder(id, nil)
params.FolderID = model.SetNullable[int64](nil)
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
folderId := int64(f_id.(float64))
s.db.UpdateFeedFolder(id, &folderId)
params.FolderID = model.SetNullable(&folderId)
}
}
if link, ok := body["feed_link"]; ok {
if reflect.TypeOf(link).Kind() == reflect.String {
s.db.UpdateFeedLink(id, link.(string))
l := link.(string)
params.FeedLink = &l
}
}
s.db.UpdateFeed(id, params)
c.Out.WriteHeader(http.StatusOK)
} else if c.Req.Method == "DELETE" {
s.db.DeleteFeed(id)
@@ -355,7 +366,7 @@ func (s *Server) handleItemList(c *router.Context) {
perPage := 20
query := c.Req.URL.Query()
filter := storage.ItemFilter{}
filter := model.ItemFilter{}
if folderID, err := c.QueryInt64("folder_id"); err == nil {
filter.FolderID = &folderID
}
@@ -366,7 +377,7 @@ func (s *Server) handleItemList(c *router.Context) {
filter.After = &after
}
if status := query.Get("status"); len(status) != 0 {
statusValue := storage.StatusValues[status]
statusValue := model.StatusValues[status]
filter.Status = &statusValue
}
if search := query.Get("search"); len(search) != 0 {
@@ -387,12 +398,12 @@ func (s *Server) handleItemList(c *router.Context) {
items[i].Title = htmlutil.TruncateText(text, 140)
}
}
c.JSON(http.StatusOK, map[string]interface{}{
c.JSON(http.StatusOK, map[string]any{
"list": items,
"has_more": hasMore,
})
} else if c.Req.Method == "PUT" {
filter := storage.MarkFilter{}
filter := model.MarkFilter{}
if folderID, err := c.QueryInt64("folder_id"); err == nil {
filter.FolderID = &folderID
@@ -411,14 +422,14 @@ func (s *Server) handleSettings(c *router.Context) {
if c.Req.Method == "GET" {
c.JSON(http.StatusOK, s.db.GetSettings())
} else if c.Req.Method == "PUT" {
settings := make(map[string]interface{})
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
var params model.UpdateSettingsParams
if err := json.NewDecoder(c.Req.Body).Decode(&params); err != nil {
c.Out.WriteHeader(http.StatusBadRequest)
return
}
if s.db.UpdateSettings(settings) {
if _, ok := settings["refresh_rate"]; ok {
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate"))
if s.db.UpdateSettings(params) {
if params.RefreshRate != nil {
s.worker.SetRefreshRate(s.db.GetSettings().RefreshRate)
}
c.Out.WriteHeader(http.StatusOK)
} else {
@@ -441,16 +452,24 @@ func (s *Server) handleOPMLImport(c *router.Context) {
return
}
for _, f := range doc.Feeds {
s.db.CreateFeed(f.Title, "", f.SiteUrl, f.FeedUrl, nil)
s.db.CreateFeed(model.CreateFeedParams{
Title: f.Title,
Link: f.SiteUrl,
FeedLink: f.FeedUrl,
})
}
for _, f := range doc.Folders {
folder := s.db.CreateFolder(f.Title)
for _, ff := range f.AllFeeds() {
s.db.CreateFeed(ff.Title, "", ff.SiteUrl, ff.FeedUrl, &folder.Id)
s.db.CreateFeed(model.CreateFeedParams{
Title: ff.Title,
Link: ff.SiteUrl,
FeedLink: ff.FeedUrl,
FolderID: &folder.Id,
})
}
}
s.worker.FindFavicons()
s.worker.RefreshFeeds()
c.Out.WriteHeader(http.StatusOK)
@@ -466,9 +485,8 @@ func (s *Server) handleOPMLExport(c *router.Context) {
doc := opml.Folder{}
feedsByFolderID := make(map[int64][]*storage.Feed)
feedsByFolderID := make(map[int64][]*model.Feed)
for _, feed := range s.db.ListFeeds() {
feed := feed
if feed.FolderId == nil {
doc.Feeds = append(doc.Feeds, opml.Feed{
Title: feed.Title,
@@ -513,6 +531,10 @@ func (s *Server) handlePageCrawl(c *router.Context) {
})
return
}
if isInternalFromURL(url) {
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
return
}
body, err := worker.GetBody(url)
if err != nil {

View File

@@ -11,6 +11,7 @@ import (
"testing"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
func TestStatic(t *testing.T) {
@@ -79,8 +80,8 @@ func TestFeedIcons(t *testing.T) {
log.SetOutput(io.Discard)
db, _ := storage.New(":memory:")
icon := []byte("test")
feed := db.CreateFeed("", "", "", "", nil)
db.UpdateFeedIcon(feed.Id, &icon)
feed := db.CreateFeed(model.CreateFeedParams{})
db.UpdateFeed(feed.Id, model.UpdateFeedParams{Icon: model.SetNullable(&icon)})
log.SetOutput(os.Stderr)
recorder := httptest.NewRecorder()

View File

@@ -14,9 +14,9 @@ import (
type Server struct {
Addr string
db *storage.Storage
db storage.Storage
worker *worker.Worker
cache map[string]interface{}
cache map[string]any
cache_mutex *sync.Mutex
BasePath string
@@ -29,12 +29,12 @@ type Server struct {
KeyFile string
}
func NewServer(db *storage.Storage, addr string) *Server {
func NewServer(db storage.Storage, addr string) *Server {
return &Server{
db: db,
Addr: addr,
worker: worker.NewWorker(db),
cache: make(map[string]interface{}),
cache: make(map[string]any),
cache_mutex: &sync.Mutex{},
}
}
@@ -48,8 +48,7 @@ func (h *Server) GetAddr() string {
}
func (s *Server) Start() {
refreshRate := s.db.GetSettingsValueInt64("refresh_rate")
s.worker.FindFavicons()
refreshRate := s.db.GetSettings().RefreshRate
s.worker.StartFeedCleaner()
s.worker.SetRefreshRate(refreshRate)
if refreshRate > 0 {

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

@@ -1,214 +0,0 @@
package storage
import (
"database/sql"
"log"
)
type Feed struct {
Id int64 `json:"id"`
FolderId *int64 `json:"folder_id"`
Title string `json:"title"`
Description string `json:"description"`
Link string `json:"link"`
FeedLink string `json:"feed_link"`
Icon *[]byte `json:"icon,omitempty"`
HasIcon bool `json:"has_icon"`
}
func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId *int64) *Feed {
if title == "" {
title = feedLink
}
row := s.db.QueryRow(`
insert into feeds (title, description, link, feed_link, folder_id)
values (?, ?, ?, ?, ?)
on conflict (feed_link) do update set folder_id = ?
returning id`,
title, description, link, feedLink, folderId,
folderId,
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &Feed{
Id: id,
Title: title,
Description: description,
Link: link,
FeedLink: feedLink,
FolderId: folderId,
}
}
func (s *Storage) DeleteFeed(feedId int64) bool {
result, err := s.db.Exec(`delete from feeds where id = ?`, feedId)
if err != nil {
log.Print(err)
return false
}
nrows, err := result.RowsAffected()
if err != nil {
if err != sql.ErrNoRows {
log.Print(err)
}
return false
}
return nrows == 1
}
func (s *Storage) RenameFeed(feedId int64, newTitle string) bool {
_, err := s.db.Exec(`update feeds set title = ? where id = ?`, newTitle, feedId)
return err == nil
}
func (s *Storage) UpdateFeedFolder(feedId int64, newFolderId *int64) bool {
_, err := s.db.Exec(`update feeds set folder_id = ? where id = ?`, newFolderId, feedId)
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 {
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
return err == nil
}
func (s *Storage) ListFeeds() []Feed {
result := make([]Feed, 0)
rows, err := s.db.Query(`
select id, folder_id, title, description, link, feed_link,
ifnull(length(icon), 0) > 0 as has_icon
from feeds
order by title collate nocase
`)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var f Feed
err = rows.Scan(
&f.Id,
&f.FolderId,
&f.Title,
&f.Description,
&f.Link,
&f.FeedLink,
&f.HasIcon,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}
func (s *Storage) ListFeedsMissingIcons() []Feed {
result := make([]Feed, 0)
rows, err := s.db.Query(`
select id, folder_id, title, description, link, feed_link
from feeds
where icon is null
`)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var f Feed
err = rows.Scan(
&f.Id,
&f.FolderId,
&f.Title,
&f.Description,
&f.Link,
&f.FeedLink,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}
func (s *Storage) GetFeed(id int64) *Feed {
var f Feed
err := s.db.QueryRow(`
select
id, folder_id, title, link, feed_link,
icon, ifnull(icon, '') != '' as has_icon
from feeds where id = ?
`, id).Scan(
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
&f.Icon, &f.HasIcon,
)
if err != nil {
if err != sql.ErrNoRows {
log.Print(err)
}
return nil
}
return &f
}
func (s *Storage) ResetFeedErrors() {
if _, err := s.db.Exec(`delete from feed_errors`); err != nil {
log.Print(err)
}
}
func (s *Storage) SetFeedError(feedID int64, lastError error) {
_, err := s.db.Exec(`
insert into feed_errors (feed_id, error)
values (?, ?)
on conflict (feed_id) do update set error = excluded.error`,
feedID, lastError.Error(),
)
if err != nil {
log.Print(err)
}
}
func (s *Storage) GetFeedErrors() map[int64]string {
errors := make(map[int64]string)
rows, err := s.db.Query(`select feed_id, error from feed_errors`)
if err != nil {
log.Print(err)
return errors
}
for rows.Next() {
var id int64
var error string
if err = rows.Scan(&id, &error); err != nil {
log.Print(err)
}
errors[id] = error
}
return errors
}
func (s *Storage) SetFeedSize(feedId int64, size int) {
_, err := s.db.Exec(`
insert into feed_sizes (feed_id, size)
values (?, ?)
on conflict (feed_id) do update set size = excluded.size`,
feedId, size,
)
if err != nil {
log.Print(err)
}
}

View File

@@ -1,87 +0,0 @@
package storage
import (
"reflect"
"testing"
)
func TestCreateFeed(t *testing.T) {
db := testDB()
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
if feed1 == nil || feed1.Id == 0 {
t.Fatal("expected feed")
}
feed2 := db.GetFeed(feed1.Id)
if feed2 == nil || !reflect.DeepEqual(feed1, feed2) {
t.Fatal("invalid feed")
}
}
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) {
db := testDB()
if db.GetFeed(100500) != nil {
t.Fatal("cannot get nonexistent feed")
}
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
feed2 := db.CreateFeed("feed 2", "", "http://example2.com", "http://example2.com/feed.xml", nil)
feeds := db.ListFeeds()
if !reflect.DeepEqual(feeds, []Feed{*feed1, *feed2}) {
t.Fatalf("invalid feed list: %#v", feeds)
}
}
func TestUpdateFeed(t *testing.T) {
db := testDB()
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
folder := db.CreateFolder("test")
icon := []byte("icon")
db.RenameFeed(feed1.Id, "newtitle")
db.UpdateFeedFolder(feed1.Id, &folder.Id)
db.UpdateFeedIcon(feed1.Id, &icon)
feed2 := db.GetFeed(feed1.Id)
if feed2.Title != "newtitle" {
t.Error("invalid title")
}
if feed2.FolderId == nil || *feed2.FolderId != folder.Id {
t.Error("invalid folder")
}
if !feed2.HasIcon || string(*feed2.Icon) != "icon" {
t.Error("invalid icon")
}
}
func TestDeleteFeed(t *testing.T) {
db := testDB()
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
if db.DeleteFeed(100500) {
t.Error("cannot delete what does not exist")
}
if !db.DeleteFeed(feed1.Id) {
t.Fatal("did not delete existing feed")
}
if db.GetFeed(feed1.Id) != nil {
t.Fatal("feed still exists")
}
}

View File

@@ -1,72 +0,0 @@
package storage
import (
"log"
)
type Folder struct {
Id int64 `json:"id"`
Title string `json:"title"`
IsExpanded bool `json:"is_expanded"`
}
func (s *Storage) CreateFolder(title string) *Folder {
expanded := true
row := s.db.QueryRow(`
insert into folders (title, is_expanded) values (?, ?)
on conflict (title) do update set title = ?
returning id`,
title, expanded,
// provide title again so that we can extract row id
title,
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &Folder{Id: id, Title: title, IsExpanded: expanded}
}
func (s *Storage) DeleteFolder(folderId int64) bool {
_, err := s.db.Exec(`delete from folders where id = ?`, folderId)
if err != nil {
log.Print(err)
}
return err == nil
}
func (s *Storage) RenameFolder(folderId int64, newTitle string) bool {
_, err := s.db.Exec(`update folders set title = ? where id = ?`, newTitle, folderId)
return err == nil
}
func (s *Storage) ToggleFolderExpanded(folderId int64, isExpanded bool) bool {
_, err := s.db.Exec(`update folders set is_expanded = ? where id = ?`, isExpanded, folderId)
return err == nil
}
func (s *Storage) ListFolders() []Folder {
result := make([]Folder, 0, 0)
rows, err := s.db.Query(`
select id, title, is_expanded
from folders
order by title collate nocase
`)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var f Folder
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}

View File

@@ -1,73 +0,0 @@
package storage
import (
"log"
"time"
)
type HTTPState struct {
FeedID int64
LastRefreshed time.Time
LastModified string
Etag string
}
func (s *Storage) ListHTTPStates() map[int64]HTTPState {
result := make(map[int64]HTTPState)
rows, err := s.db.Query(`select feed_id, last_refreshed, last_modified, etag from http_states`)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var state HTTPState
err = rows.Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastModified,
&state.Etag,
)
if err != nil {
log.Print(err)
return result
}
result[state.FeedID] = state
}
return result
}
func (s *Storage) GetHTTPState(feedID int64) *HTTPState {
row := s.db.QueryRow(`
select feed_id, last_refreshed, last_modified, etag
from http_states where feed_id = ?
`, feedID)
if row == nil {
return nil
}
var state HTTPState
row.Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastModified,
&state.Etag,
)
return &state
}
func (s *Storage) SetHTTPState(feedID int64, lastModified, etag string) {
_, err := s.db.Exec(`
insert into http_states (feed_id, last_modified, etag, last_refreshed)
values (?, ?, ?, datetime())
on conflict (feed_id) do update set last_modified = ?, etag = ?, last_refreshed = datetime()`,
// insert
feedID, lastModified, etag,
// upsert
lastModified, etag,
)
if err != nil {
log.Print(err)
}
}

View File

@@ -1,464 +0,0 @@
package storage
import (
"database/sql/driver"
"encoding/json"
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/nkanaev/yarr/src/content/htmlutil"
)
type ItemStatus int
const (
UNREAD ItemStatus = 0
READ ItemStatus = 1
STARRED ItemStatus = 2
)
var StatusRepresentations = map[ItemStatus]string{
UNREAD: "unread",
READ: "read",
STARRED: "starred",
}
var StatusValues = map[string]ItemStatus{
"unread": UNREAD,
"read": READ,
"starred": STARRED,
}
func (s ItemStatus) MarshalJSON() ([]byte, error) {
return json.Marshal(StatusRepresentations[s])
}
func (s *ItemStatus) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = StatusValues[str]
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 {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, m)
}
return nil
}
func (m MediaLinks) Value() (driver.Value, error) {
return json.Marshal(m)
}
type Item struct {
Id int64 `json:"id"`
GUID string `json:"guid"`
FeedId int64 `json:"feed_id"`
Title string `json:"title"`
Link string `json:"link"`
Content string `json:"content,omitempty"`
Date time.Time `json:"date"`
Status ItemStatus `json:"status"`
MediaLinks MediaLinks `json:"media_links"`
}
type ItemFilter struct {
FolderID *int64
FeedID *int64
Status *ItemStatus
Search *string
After *int64
IDs *[]int64
SinceID *int64
MaxID *int64
Before *time.Time
}
type MarkFilter struct {
FolderID *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 {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
now := time.Now().UTC()
itemsSorted := ItemList(items)
sort.Sort(itemsSorted)
for _, item := range itemsSorted {
_, err = tx.Exec(`
insert into items (
guid, feed_id, title, link, date,
content, media_links,
date_arrived, status
)
values (
?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?),
?, ?,
?, ?
)
on conflict (feed_id, guid) do nothing`,
item.GUID, item.FeedId, item.Title, item.Link, item.Date,
item.Content, item.MediaLinks,
now, UNREAD,
)
if err != nil {
log.Print(err)
if err = tx.Rollback(); err != nil {
log.Print(err)
return false
}
return false
}
}
if err = tx.Commit(); err != nil {
log.Print(err)
return false
}
return true
}
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interface{}) {
cond := make([]string, 0)
args := make([]interface{}, 0)
if filter.FolderID != nil {
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = ?)")
args = append(args, *filter.FolderID)
}
if filter.FeedID != nil {
cond = append(cond, "i.feed_id = ?")
args = append(args, *filter.FeedID)
}
if filter.Status != nil {
cond = append(cond, "i.status = ?")
args = append(args, *filter.Status)
}
if filter.Search != nil {
words := strings.Fields(*filter.Search)
terms := make([]string, len(words))
for idx, word := range words {
terms[idx] = word + "*"
}
cond = append(cond, "i.search_rowid in (select rowid from search where search match ?)")
args = append(args, strings.Join(terms, " "))
}
if filter.After != nil {
compare := ">"
if newestFirst {
compare = "<"
}
cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare))
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"
if len(cond) > 0 {
predicate = strings.Join(cond, " and ")
}
return predicate, args
}
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)
result := make([]Item, 0, 0)
order := "date desc, id desc"
if !newestFirst {
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(`
select %s
from items i
where %s
order by %s
limit %d
`, selectCols, predicate, order, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var x Item
err = rows.Scan(
&x.Id, &x.GUID, &x.FeedId,
&x.Title, &x.Link, &x.Date,
&x.Status, &x.MediaLinks, &x.Content,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, x)
}
return result
}
func (s *Storage) GetItem(id int64) *Item {
i := &Item{}
err := s.db.QueryRow(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.media_links
from items i
where i.id = ?
`, id).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, &i.MediaLinks,
)
if err != nil {
log.Print(err)
return nil
}
return i
}
func (s *Storage) UpdateItemStatus(item_id int64, status ItemStatus) bool {
_, err := s.db.Exec(`update items set status = ? where id = ?`, status, item_id)
return err == nil
}
func (s *Storage) MarkItemsRead(filter MarkFilter) bool {
predicate, args := listQueryPredicate(ItemFilter{
FolderID: filter.FolderID,
FeedID: filter.FeedID,
Before: filter.Before,
}, false)
query := fmt.Sprintf(`
update items as i set status = %d
where %s and i.status != %d
`, READ, predicate, STARRED)
_, err := s.db.Exec(query, args...)
if err != nil {
log.Print(err)
}
return err == nil
}
type FeedStat struct {
FeedId int64 `json:"feed_id"`
UnreadCount int64 `json:"unread"`
StarredCount int64 `json:"starred"`
}
func (s *Storage) FeedStats() []FeedStat {
result := make([]FeedStat, 0)
rows, err := s.db.Query(fmt.Sprintf(`
select
feed_id,
sum(case status when %d then 1 else 0 end),
sum(case status when %d then 1 else 0 end)
from items
group by feed_id
`, UNREAD, STARRED))
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
stat := FeedStat{}
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
result = append(result, stat)
}
return result
}
func (s *Storage) SyncSearch() {
rows, err := s.db.Query(`
select id, title, content
from items
where search_rowid is null;
`)
if err != nil {
log.Print(err)
return
}
items := make([]Item, 0)
for rows.Next() {
var item Item
rows.Scan(&item.Id, &item.Title, &item.Content)
items = append(items, item)
}
for _, item := range items {
result, err := s.db.Exec(`
insert into search (title, description, content) values (?, "", ?)`,
item.Title, htmlutil.ExtractText(item.Content),
)
if err != nil {
log.Print(err)
return
}
if numrows, err := result.RowsAffected(); err == nil && numrows == 1 {
if rowId, err := result.LastInsertId(); err == nil {
s.db.Exec(
`update items set search_rowid = ? where id = ?`,
rowId, item.Id,
)
}
}
}
}
var (
itemsKeepSize = 50
itemsKeepDays = 90
)
// Delete old articles from the database to cleanup space.
//
// The rules:
// - Never delete starred entries.
// - 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
// feeds which might eventually reappear as unread.
// - Keep entries for a certain period (default: 90 days).
func (s *Storage) DeleteOldItems() {
rows, err := s.db.Query(`
select
i.feed_id,
max(coalesce(s.size, 0), ?) as max_items,
count(*) as num_items
from items i
left outer join feed_sizes s on s.feed_id = i.feed_id
where status != ?
group by i.feed_id
`, itemsKeepSize, STARRED)
if err != nil {
log.Print(err)
return
}
feedLimits := make(map[int64]int64, 0)
for rows.Next() {
var feedId, limit int64
rows.Scan(&feedId, &limit, nil)
feedLimits[feedId] = limit
}
for feedId, limit := range feedLimits {
result, err := s.db.Exec(`
delete from items
where id in (
select i.id
from items i
where i.feed_id = ? and status != ?
order by date desc
limit -1 offset ?
) and date_arrived < ?
`,
feedId,
STARRED,
limit,
time.Now().UTC().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
)
if err != nil {
log.Print(err)
return
}
numDeleted, err := result.RowsAffected()
if err != nil {
log.Print(err)
return
}
if numDeleted > 0 {
log.Printf("Deleted %d old items (feed: %d)", numDeleted, feedId)
}
}
}

View File

@@ -1,330 +0,0 @@
package storage
import (
"log"
"reflect"
"strconv"
"testing"
"time"
)
/*
- folder1
- feed11
- item111 (unread)
- item112 (read)
- item113 (starred)
- feed12
- item121 (unread)
- item122 (read)
- folder2
- feed21
- item211 (read)
- item212 (starred)
- feed01
- item011 (unread)
- item012 (read)
- item013 (starred)
*/
type testItemScope struct {
feed11, feed12 *Feed
feed21, feed01 *Feed
folder1, folder2 *Folder
}
func testItemsSetup(db *Storage) testItemScope {
folder1 := db.CreateFolder("folder1")
folder2 := db.CreateFolder("folder2")
feed11 := db.CreateFeed("feed11", "", "", "http://test.com/feed11.xml", &folder1.Id)
feed12 := db.CreateFeed("feed12", "", "", "http://test.com/feed12.xml", &folder1.Id)
feed21 := db.CreateFeed("feed21", "", "", "http://test.com/feed21.xml", &folder2.Id)
feed01 := db.CreateFeed("feed01", "", "", "http://test.com/feed01.xml", nil)
now := time.Now()
db.CreateItems([]Item{
// feed11
{GUID: "item111", FeedId: feed11.Id, Title: "title111", Date: now.Add(time.Hour * 24 * 1)},
{GUID: "item112", FeedId: feed11.Id, Title: "title112", Date: now.Add(time.Hour * 24 * 2)}, // read
{GUID: "item113", FeedId: feed11.Id, Title: "title113", Date: now.Add(time.Hour * 24 * 3)}, // starred
// feed12
{GUID: "item121", FeedId: feed12.Id, Title: "title121", Date: now.Add(time.Hour * 24 * 4)},
{GUID: "item122", FeedId: feed12.Id, Title: "title122", Date: now.Add(time.Hour * 24 * 5)}, // read
// feed21
{GUID: "item211", FeedId: feed21.Id, Title: "title211", Date: now.Add(time.Hour * 24 * 6)}, // read
{GUID: "item212", FeedId: feed21.Id, Title: "title212", Date: now.Add(time.Hour * 24 * 7)}, // starred
// feed01
{GUID: "item011", FeedId: feed01.Id, Title: "title011", Date: now.Add(time.Hour * 24 * 8)},
{GUID: "item012", FeedId: feed01.Id, Title: "title012", Date: now.Add(time.Hour * 24 * 9)}, // read
{GUID: "item013", FeedId: feed01.Id, Title: "title013", Date: now.Add(time.Hour * 24 * 10)}, // starred
})
db.db.Exec(`update items set status = ? where guid in ("item112", "item122", "item211", "item012")`, READ)
db.db.Exec(`update items set status = ? where guid in ("item113", "item212", "item013")`, STARRED)
return testItemScope{
feed11: feed11,
feed12: feed12,
feed21: feed21,
feed01: feed01,
folder1: folder1,
folder2: folder2,
}
}
func getItem(db *Storage, guid string) *Item {
i := &Item{}
err := db.db.QueryRow(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.media_links
from items i
where i.guid = ?
`, guid).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, &i.MediaLinks,
)
if err != nil {
log.Fatal(err)
}
return i
}
func getItemGuids(items []Item) []string {
guids := make([]string, 0)
for _, item := range items {
guids = append(guids, item.GUID)
}
return guids
}
func TestListItems(t *testing.T) {
db := testDB()
scope := testItemsSetup(db)
// filter by folder_id
have := getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 10, false, false))
want := []string{"item111", "item112", "item113", "item121", "item122"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
have = getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 10, false, false))
want = []string{"item211", "item212"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// filter by feed_id
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 10, false, false))
want = []string{"item111", "item112", "item113"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 10, false, false))
want = []string{"item011", "item012", "item013"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// filter by status
var starred ItemStatus = STARRED
have = getItemGuids(db.ListItems(ItemFilter{Status: &starred}, 10, false, false))
want = []string{"item113", "item212", "item013"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
var unread ItemStatus = UNREAD
have = getItemGuids(db.ListItems(ItemFilter{Status: &unread}, 10, false, false))
want = []string{"item111", "item121", "item011"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// limit
have = getItemGuids(db.ListItems(ItemFilter{}, 2, false, false))
want = []string{"item111", "item112"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// filter by search
db.SyncSearch()
search1 := "title111"
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
want = []string{"item111"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// sort by date
have = getItemGuids(db.ListItems(ItemFilter{}, 4, true, false))
want = []string{"item013", "item012", "item011", "item212"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
}
func TestListItemsPaginated(t *testing.T) {
db := testDB()
testItemsSetup(db)
item012 := getItem(db, "item012")
item121 := getItem(db, "item121")
// all, newest first
have := getItemGuids(db.ListItems(ItemFilter{After: &item012.Id}, 3, true, false))
want := []string{"item011", "item212", "item211"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// unread, newest first
unread := UNREAD
have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false))
want = []string{"item011", "item121", "item111"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// starred, oldest first
starred := STARRED
have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false))
want = []string{"item212", "item013"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
}
func TestMarkItemsRead(t *testing.T) {
// NOTE: starred items must not be marked as read
var read ItemStatus = READ
db1 := testDB()
testItemsSetup(db1)
db1.MarkItemsRead(MarkFilter{})
have := getItemGuids(db1.ListItems(ItemFilter{Status: &read}, 10, false, false))
want := []string{
"item111", "item112", "item121", "item122",
"item211", "item011", "item012",
}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
db2 := testDB()
scope2 := testItemsSetup(db2)
db2.MarkItemsRead(MarkFilter{FolderID: &scope2.folder1.Id})
have = getItemGuids(db2.ListItems(ItemFilter{Status: &read}, 10, false, false))
want = []string{
"item111", "item112", "item121", "item122",
"item211", "item012",
}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
db3 := testDB()
scope3 := testItemsSetup(db3)
db3.MarkItemsRead(MarkFilter{FeedID: &scope3.feed11.Id})
have = getItemGuids(db3.ListItems(ItemFilter{Status: &read}, 10, false, false))
want = []string{
"item111", "item112", "item122",
"item211", "item012",
}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
}
func TestDeleteOldItems(t *testing.T) {
extraItems := 10
now := time.Now().UTC()
db := testDB()
feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil)
items := make([]Item, 0)
for i := 0; i < itemsKeepSize+extraItems; i++ {
istr := strconv.Itoa(i)
items = append(items, Item{
GUID: istr,
FeedId: feed.Id,
Title: istr,
Date: now.Add(time.Hour * time.Duration(i)),
})
}
db.CreateItems(items)
db.SetFeedSize(feed.Id, itemsKeepSize)
var feedSize int
err := db.db.QueryRow(
`select size from feed_sizes where feed_id = ?`, feed.Id,
).Scan(&feedSize)
if err != nil {
t.Fatal(err)
}
if feedSize != itemsKeepSize {
t.Fatalf(
"expected feed size to get updated\nwant: %d\nhave: %d",
itemsKeepSize+extraItems,
feedSize,
)
}
// expire only the first 3 articles
_, err = db.db.Exec(
`update items set date_arrived = ?
where id in (select id from items limit 3)`,
now.Add(-time.Hour*time.Duration(itemsKeepDays*24)),
)
if err != nil {
t.Fatal(err)
}
db.DeleteOldItems()
feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false, false)
if len(feedItems) != len(items)-3 {
t.Fatalf(
"invalid number of old items kept\nwant: %d\nhave: %d",
len(items)-3,
len(feedItems),
)
}
}

209
src/storage/model/model.go Normal file
View File

@@ -0,0 +1,209 @@
package model
import (
"encoding/json"
"time"
)
type Feed struct {
Id int64 `json:"id"`
FolderId *int64 `json:"folder_id"`
Title string `json:"title"`
Description string `json:"description"`
Link string `json:"link"`
FeedLink string `json:"feed_link"`
Icon *[]byte `json:"icon,omitempty"`
HasIcon bool `json:"has_icon"`
}
type CreateFeedParams struct {
Title string
Description string
Link string
FeedLink string
FolderID *int64
}
type Item struct {
Id int64 `json:"id"`
GUID string `json:"guid"`
FeedId int64 `json:"feed_id"`
Title string `json:"title"`
Link string `json:"link"`
Content string `json:"content,omitempty"`
Date time.Time `json:"date"`
Status ItemStatus `json:"status"`
MediaLinks MediaLinks `json:"media_links"`
}
type ItemStatus int
const (
UNREAD ItemStatus = 0
READ ItemStatus = 1
STARRED ItemStatus = 2
)
var StatusRepresentations = map[ItemStatus]string{
UNREAD: "unread",
READ: "read",
STARRED: "starred",
}
var StatusValues = map[string]ItemStatus{
"unread": UNREAD,
"read": READ,
"starred": STARRED,
}
func (s ItemStatus) MarshalJSON() ([]byte, error) {
return json.Marshal(StatusRepresentations[s])
}
func (s *ItemStatus) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = StatusValues[str]
return nil
}
type MediaLink struct {
URL string `json:"url"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
}
type MediaLinks []MediaLink
type ItemFilter struct {
FolderID *int64
FeedID *int64
Status *ItemStatus
Search *string
After *int64
IDs *[]int64
SinceID *int64
MaxID *int64
Before *time.Time
}
type UpdateItemParams struct {
Title *string
Status *ItemStatus
LastArrived *time.Time
}
type MarkFilter struct {
FolderID *int64
FeedID *int64
Before *time.Time
}
type Folder struct {
Id int64 `json:"id"`
Title string `json:"title"`
IsExpanded bool `json:"is_expanded"`
}
type UpdateFolderParams struct {
Title *string
IsExpanded *bool
}
type FeedStat struct {
FeedId int64 `json:"feed_id"`
UnreadCount int64 `json:"unread"`
StarredCount int64 `json:"starred"`
}
type Settings struct {
Filter string `json:"filter"`
Feed string `json:"feed"`
FeedListWidth int `json:"feed_list_width"`
ItemListWidth int `json:"item_list_width"`
SortNewestFirst bool `json:"sort_newest_first"`
ThemeName string `json:"theme_name"`
ThemeFont string `json:"theme_font"`
ThemeSize int `json:"theme_size"`
RefreshRate int64 `json:"refresh_rate"`
Language string `json:"language"`
}
type UpdateSettingsParams struct {
Filter *string `json:"filter"`
Feed *string `json:"feed"`
FeedListWidth *int `json:"feed_list_width"`
ItemListWidth *int `json:"item_list_width"`
SortNewestFirst *bool `json:"sort_newest_first"`
ThemeName *string `json:"theme_name"`
ThemeFont *string `json:"theme_font"`
ThemeSize *int `json:"theme_size"`
RefreshRate *int64 `json:"refresh_rate"`
Language *string `json:"language"`
}
func (s Settings) Map() map[string]any {
return map[string]any{
"filter": s.Filter,
"feed": s.Feed,
"feed_list_width": s.FeedListWidth,
"item_list_width": s.ItemListWidth,
"sort_newest_first": s.SortNewestFirst,
"theme_name": s.ThemeName,
"theme_font": s.ThemeFont,
"theme_size": s.ThemeSize,
"refresh_rate": s.RefreshRate,
"language": s.Language,
}
}
func SettingsDefault() Settings {
return Settings{
Filter: "",
Feed: "",
FeedListWidth: 300,
ItemListWidth: 300,
SortNewestFirst: true,
ThemeName: "light",
ThemeFont: "",
ThemeSize: 1,
RefreshRate: 0,
Language: "en",
}
}
type FeedState struct {
FeedID int64
LastRefreshed time.Time
LastError string
HTTPLastModified string
HTTPEtag string
}
type UpdateFeedStateParams struct {
LastRefreshed *time.Time
LastError *string
HTTPLastModified *string
HTTPEtag *string
}
type UpdateFeedParams struct {
Title *string
FeedLink *string
FolderID Nullable[int64]
Icon Nullable[[]byte]
}
type Nullable[T any] struct {
Set bool
Value *T
}
func SetNullable[T any](v *T) Nullable[T] {
return Nullable[T]{Set: true, Value: v}
}

View File

@@ -0,0 +1,133 @@
package postgres
import (
"database/sql"
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *PostgresStorage) CreateFeed(params model.CreateFeedParams) *model.Feed {
title := params.Title
if title == "" {
title = params.FeedLink
}
row := s.db.QueryRow(`
insert into feeds (title, description, link, feed_link, folder_id)
values ($1, $2, $3, $4, $5)
on conflict (feed_link) do update set folder_id = $5
returning id`,
title,
params.Description,
params.Link,
params.FeedLink,
params.FolderID,
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &model.Feed{
Id: id,
Title: title,
Description: params.Description,
Link: params.Link,
FeedLink: params.FeedLink,
FolderId: params.FolderID,
}
}
func (s *PostgresStorage) DeleteFeed(feedId int64) bool {
result, err := s.db.Exec(`delete from feeds where id = $1`, feedId)
if err != nil {
log.Print(err)
return false
}
nrows, err := result.RowsAffected()
if err != nil {
log.Print(err)
return false
}
return nrows == 1
}
func (s *PostgresStorage) UpdateFeed(feedId int64, params model.UpdateFeedParams) (bool, error) {
_, err := s.db.Exec(`
update feeds set
title = coalesce($2, title),
feed_link = coalesce($3, feed_link),
folder_id = case when $4 then $5 else folder_id end,
icon = case when $6 then $7 else icon end
where id = $1
`,
feedId,
params.Title,
params.FeedLink,
params.FolderID.Set,
params.FolderID.Value,
params.Icon.Set,
params.Icon.Value,
)
if err != nil {
log.Print(err)
return false, err
}
return true, nil
}
func (s *PostgresStorage) ListFeeds() []model.Feed {
result := make([]model.Feed, 0)
rows, err := s.db.Query(`
select id, folder_id, title, description, link, feed_link,
coalesce(length(icon), 0) > 0 as has_icon
from feeds
order by lower(title)
`)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var f model.Feed
err = rows.Scan(
&f.Id,
&f.FolderId,
&f.Title,
&f.Description,
&f.Link,
&f.FeedLink,
&f.HasIcon,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}
func (s *PostgresStorage) GetFeed(id int64) *model.Feed {
var f model.Feed
err := s.db.QueryRow(`
select
id, folder_id, title, link, feed_link,
icon, coalesce(length(icon), 0) > 0 as has_icon
from feeds where id = $1
`, id).Scan(
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
&f.Icon, &f.HasIcon,
)
if err != nil {
if err != sql.ErrNoRows {
log.Print(err)
}
return nil
}
return &f
}

View File

@@ -0,0 +1,105 @@
package postgres
import (
"database/sql"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *PostgresStorage) ListFeedStates() ([]model.FeedState, error) {
rows, err := s.db.Query(`
select
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
from feed_states
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := make([]model.FeedState, 0)
for rows.Next() {
var state model.FeedState
err := rows.Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastError,
&state.HTTPLastModified,
&state.HTTPEtag,
)
if err != nil {
return nil, err
}
states = append(states, state)
}
return states, nil
}
func (s *PostgresStorage) GetFeedState(feedID int64) (*model.FeedState, error) {
var state model.FeedState
err := s.db.QueryRow(`
select
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
from feed_states where feed_id = $1
`, feedID).Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastError,
&state.HTTPLastModified,
&state.HTTPEtag,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &state, nil
}
func (s *PostgresStorage) UpdateFeedState(feedID int64, params model.UpdateFeedStateParams) (bool, error) {
lastError := params.LastError
if lastError != nil && *lastError == "" {
lastError = nil
}
_, err := s.db.Exec(`
insert into feed_states (
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
)
values (
$1
, coalesce($2, '1970-01-01 00:00:00+00'::timestamptz)
, coalesce($3, '')
, coalesce($4, '')
, coalesce($5, '')
)
on conflict (feed_id) do update set
last_refreshed = coalesce($2, feed_states.last_refreshed),
last_error = coalesce($3, feed_states.last_error),
http_lmod = coalesce($4, feed_states.http_lmod),
http_etag = coalesce($5, feed_states.http_etag)
`,
feedID,
params.LastRefreshed,
params.LastError,
params.HTTPLastModified,
params.HTTPEtag,
)
if err != nil {
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,77 @@
package postgres
import (
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *PostgresStorage) CreateFolder(title string) *model.Folder {
expanded := true
row := s.db.QueryRow(`
insert into folders (title, is_expanded) values ($1, $2)
on conflict (title) do update set title = $1
returning id`,
title,
expanded,
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &model.Folder{Id: id, Title: title, IsExpanded: expanded}
}
func (s *PostgresStorage) DeleteFolder(folderId int64) bool {
_, err := s.db.Exec(`delete from folders where id = $1`, folderId)
if err != nil {
log.Print(err)
}
return err == nil
}
func (s *PostgresStorage) UpdateFolder(folderId int64, params model.UpdateFolderParams) (bool, error) {
_, err := s.db.Exec(`
update folders set
title = coalesce($2, title),
is_expanded = coalesce($3, is_expanded)
where id = $1
`,
folderId,
params.Title,
params.IsExpanded,
)
if err != nil {
log.Print(err)
return false, err
}
return true, nil
}
func (s *PostgresStorage) ListFolders() []model.Folder {
result := make([]model.Folder, 0)
rows, err := s.db.Query(`
select id, title, is_expanded
from folders
order by lower(title)
`)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var f model.Folder
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}

View File

@@ -0,0 +1,376 @@
package postgres
import (
"cmp"
"database/sql/driver"
"encoding/json"
"fmt"
"log"
"slices"
"strings"
"time"
"github.com/nkanaev/yarr/src/content/htmlutil"
"github.com/nkanaev/yarr/src/storage/model"
)
type MediaLinks model.MediaLinks
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)
}
func (s *PostgresStorage) CreateItems(items []model.Item) bool {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
now := time.Now().UTC()
slices.SortStableFunc(items, func(a, b model.Item) int {
sa := a.Date.Format(time.RFC3339) + "::" + a.GUID
sb := b.Date.Format(time.RFC3339) + "::" + b.GUID
return cmp.Compare(sa, sb)
})
for _, item := range items {
searchText := item.Title + " " + htmlutil.ExtractText(item.Content)
_, err = tx.Exec(`
insert into items (
guid, feed_id, title, link, date,
content, media_links,
date_arrived, last_arrived, status,
search
)
values (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10,
to_tsvector('simple', $11)
)
on conflict (feed_id, guid) do update set
last_arrived = excluded.last_arrived`,
item.GUID,
item.FeedId,
item.Title,
item.Link,
item.Date,
item.Content,
MediaLinks(item.MediaLinks),
now,
now,
item.Status,
searchText,
)
if err != nil {
log.Print(err)
if err = tx.Rollback(); err != nil {
log.Print(err)
return false
}
return false
}
}
if err = tx.Commit(); err != nil {
log.Print(err)
return false
}
return true
}
func listQueryPredicate(filter model.ItemFilter, newestFirst bool) (string, []any) {
cond := make([]string, 0)
args := make([]any, 0)
n := 0
next := func() int {
n++
return n
}
if filter.FolderID != nil {
cond = append(cond, fmt.Sprintf("i.feed_id in (select id from feeds where folder_id = $%d)", next()))
args = append(args, *filter.FolderID)
}
if filter.FeedID != nil {
cond = append(cond, fmt.Sprintf("i.feed_id = $%d", next()))
args = append(args, *filter.FeedID)
}
if filter.Status != nil {
cond = append(cond, fmt.Sprintf("i.status = $%d", next()))
args = append(args, *filter.Status)
}
if filter.Search != nil {
words := strings.Fields(*filter.Search)
terms := make([]string, len(words))
for idx, word := range words {
terms[idx] = word + ":*"
}
cond = append(cond, fmt.Sprintf(
"i.search @@ to_tsquery('simple', $%d)", next(),
))
args = append(args, strings.Join(terms, " & "))
}
if filter.After != nil {
compare := ">"
if newestFirst {
compare = "<"
}
cond = append(cond, fmt.Sprintf(
"(i.date, i.id) %s (select date, id from items where id = $%d)",
compare, next(),
))
args = append(args, *filter.After)
}
if filter.IDs != nil && len(*filter.IDs) > 0 {
placeholders := make([]string, len(*filter.IDs))
for i, id := range *filter.IDs {
placeholders[i] = fmt.Sprintf("$%d", next())
args = append(args, id)
}
cond = append(cond, "i.id in ("+strings.Join(placeholders, ",")+")")
}
if filter.SinceID != nil {
cond = append(cond, fmt.Sprintf("i.id > $%d", next()))
args = append(args, filter.SinceID)
}
if filter.MaxID != nil {
cond = append(cond, fmt.Sprintf("i.id < $%d", next()))
args = append(args, filter.MaxID)
}
if filter.Before != nil {
cond = append(cond, fmt.Sprintf("i.date < $%d", next()))
args = append(args, filter.Before)
}
predicate := "true"
if len(cond) > 0 {
predicate = strings.Join(cond, " and ")
}
return predicate, args
}
func (s *PostgresStorage) CountItems() int {
var count int
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
if err != nil {
log.Print(err)
return 0
}
return count
}
func (s *PostgresStorage) ListItems(
filter model.ItemFilter,
limit int,
newestFirst bool,
withContent bool,
) []model.Item {
predicate, args := listQueryPredicate(filter, newestFirst)
result := make([]model.Item, 0)
order := "date desc, id desc"
if !newestFirst {
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(`
select %s
from items i
where %s
order by %s
limit %d
`, selectCols, predicate, order, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var x model.Item
err = rows.Scan(
&x.Id, &x.GUID, &x.FeedId,
&x.Title, &x.Link, &x.Date,
&x.Status, (*MediaLinks)(&x.MediaLinks), &x.Content,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, x)
}
return result
}
func (s *PostgresStorage) GetItem(id int64) *model.Item {
i := &model.Item{}
err := s.db.QueryRow(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.media_links
from items i
where i.id = $1
`, id).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, (*MediaLinks)(&i.MediaLinks),
)
if err != nil {
log.Print(err)
return nil
}
return i
}
func (s *PostgresStorage) UpdateItem(id int64, params model.UpdateItemParams) bool {
sets := make([]string, 0)
args := make([]any, 0)
n := 0
if params.Title != nil {
n++
sets = append(sets, fmt.Sprintf("title = $%d", n))
args = append(args, *params.Title)
n++
sets = append(sets, fmt.Sprintf("search = to_tsvector('simple', $%d || ' ' || coalesce((select i2.content from items i2 where i2.id = $%d), ''))", n-1, n))
args = append(args, id)
}
if params.Status != nil {
n++
sets = append(sets, fmt.Sprintf("status = $%d", n))
args = append(args, *params.Status)
}
if params.LastArrived != nil {
n++
sets = append(sets, fmt.Sprintf("last_arrived = $%d", n))
args = append(args, *params.LastArrived)
}
if len(sets) == 0 {
return true
}
n++
args = append(args, id)
query := fmt.Sprintf("update items set %s where id = $%d", strings.Join(sets, ", "), n)
_, err := s.db.Exec(query, args...)
return err == nil
}
func (s *PostgresStorage) DeleteItem(id int64) bool {
_, err := s.db.Exec(`delete from items where id = $1`, id)
return err == nil
}
func (s *PostgresStorage) UpdateItemStatus(item_id int64, status model.ItemStatus) bool {
_, err := s.db.Exec(`update items set status = $2 where id = $1`,
item_id,
status,
)
return err == nil
}
func (s *PostgresStorage) MarkItemsRead(filter model.MarkFilter) bool {
predicate, args := listQueryPredicate(model.ItemFilter{
FolderID: filter.FolderID,
FeedID: filter.FeedID,
Before: filter.Before,
}, false)
query := fmt.Sprintf(`
update items as i set status = %d
where %s and i.status != %d
`, model.READ, predicate, model.STARRED)
_, err := s.db.Exec(query, args...)
if err != nil {
log.Print(err)
}
return err == nil
}
func (s *PostgresStorage) FeedStats() []model.FeedStat {
result := make([]model.FeedStat, 0)
rows, err := s.db.Query(fmt.Sprintf(`
select
feed_id,
sum(case status when %d then 1 else 0 end),
sum(case status when %d then 1 else 0 end)
from items
group by feed_id
`, model.UNREAD, model.STARRED))
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
stat := model.FeedStat{}
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
result = append(result, stat)
}
return result
}
var (
itemsKeepSize = 50
itemsKeepDays = 90
)
func (s *PostgresStorage) DeleteOldItems() {
keepDaysLimit := fmt.Sprintf("-%d days", itemsKeepDays)
result, err := s.db.Exec(`
delete from items
where id in (
select id
from (
select
id,
row_number() over (partition by feed_id order by date desc) as rn,
last_arrived,
max(last_arrived) over (partition by feed_id) as max_la
from items
where status != $1
) sub
where rn > $2
and last_arrived < max_la + $3::interval
)`,
model.STARRED,
itemsKeepSize,
keepDaysLimit,
)
if err != nil {
log.Print(err)
return
}
numDeleted, err := result.RowsAffected()
if err == nil && numDeleted > 0 {
log.Printf("Deleted %d old items", numDeleted)
}
}

View File

@@ -0,0 +1,120 @@
package postgres
import (
"database/sql"
"fmt"
"log"
)
var migrations = []func(*sql.Tx) error{
m01_initial,
}
var maxVersion = int64(len(migrations))
func migrate(db *sql.DB) error {
if _, err := db.Exec(
`create table if not exists schema_version (version bigint primary key)`,
); err != nil {
return fmt.Errorf("create schema_version table: %w", err)
}
var version int64
err := db.QueryRow(
`select coalesce(max(version), 0) from schema_version`,
).Scan(&version)
if err != nil {
return fmt.Errorf("read schema version: %w", err)
}
if version >= maxVersion {
return nil
}
log.Printf("db version is %d. migrating to %d", version, maxVersion)
for v := version + 1; v <= maxVersion; v++ {
log.Printf("[migration:%d] starting", v)
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("migration %d begin tx: %w", v, err)
}
if err := migrations[v-1](tx); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d: %w", v, err)
}
if _, err := tx.Exec(
`insert into schema_version (version) values ($1)
on conflict do nothing`, v,
); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d record version: %w", v, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("migration %d commit: %w", v, err)
}
log.Printf("[migration:%d] done", v)
}
return nil
}
func m01_initial(tx *sql.Tx) error {
_, err := tx.Exec(`
create table if not exists folders (
id bigserial primary key,
title text not null,
is_expanded boolean not null default false
);
create unique index if not exists idx_folder_title on folders(title);
create table if not exists feeds (
id bigserial primary key,
folder_id bigint references folders(id) on delete set null,
title text not null,
description text,
link text,
feed_link text not null,
icon bytea
);
create index if not exists idx_feed_folder_id on feeds(folder_id);
create unique index if not exists idx_feed_feed_link on feeds(feed_link);
create table if not exists items (
id bigserial primary key,
guid text not null,
feed_id bigint not null references feeds(id) on delete cascade,
title text,
link text,
content text,
date timestamptz,
date_arrived timestamptz,
last_arrived timestamptz,
status integer,
media_links jsonb,
search tsvector
);
create index if not exists idx_item_feed_id on items(feed_id);
create index if not exists idx_item__date_id_status on items(date, id, status);
create unique index if not exists idx_item_guid on items(feed_id, guid);
create index if not exists idx_item_search on items using gin(search);
create table if not exists settings (
key text primary key,
val jsonb
);
create table if not exists feed_states (
feed_id bigint primary key references feeds(id) on delete cascade,
last_refreshed timestamptz not null default '1970-01-01 00:00:00+00',
last_error text not null default '',
http_lmod text not null default '',
http_etag text not null default ''
);
`)
return err
}

View File

@@ -0,0 +1,116 @@
package postgres
import (
"encoding/json"
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *PostgresStorage) GetSettings() model.Settings {
result := model.SettingsDefault()
rows, err := s.db.Query(`select key, val from settings;`)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var key string
var val []byte
rows.Scan(&key, &val)
switch key {
case "filter":
json.Unmarshal(val, &result.Filter)
case "feed":
json.Unmarshal(val, &result.Feed)
case "feed_list_width":
json.Unmarshal(val, &result.FeedListWidth)
case "item_list_width":
json.Unmarshal(val, &result.ItemListWidth)
case "sort_newest_first":
json.Unmarshal(val, &result.SortNewestFirst)
case "theme_name":
json.Unmarshal(val, &result.ThemeName)
case "theme_font":
json.Unmarshal(val, &result.ThemeFont)
case "theme_size":
json.Unmarshal(val, &result.ThemeSize)
case "refresh_rate":
json.Unmarshal(val, &result.RefreshRate)
case "language":
json.Unmarshal(val, &result.Language)
}
}
return result
}
func (s *PostgresStorage) UpdateSettings(params model.UpdateSettingsParams) bool {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
defer tx.Rollback()
update := func(key string, val any) error {
valEncoded, err := json.Marshal(val)
if err != nil {
return err
}
_, err = tx.Exec(`
insert into settings (key, val) values ($1, $2)
on conflict (key) do update set val = $2`,
key,
valEncoded,
)
return err
}
var errs []error
if params.Filter != nil {
errs = append(errs, update("filter", *params.Filter))
}
if params.Feed != nil {
errs = append(errs, update("feed", *params.Feed))
}
if params.FeedListWidth != nil {
errs = append(errs, update("feed_list_width", *params.FeedListWidth))
}
if params.ItemListWidth != nil {
errs = append(errs, update("item_list_width", *params.ItemListWidth))
}
if params.SortNewestFirst != nil {
errs = append(errs, update("sort_newest_first", *params.SortNewestFirst))
}
if params.ThemeName != nil {
errs = append(errs, update("theme_name", *params.ThemeName))
}
if params.ThemeFont != nil {
errs = append(errs, update("theme_font", *params.ThemeFont))
}
if params.ThemeSize != nil {
errs = append(errs, update("theme_size", *params.ThemeSize))
}
if params.RefreshRate != nil {
errs = append(errs, update("refresh_rate", *params.RefreshRate))
}
if params.Language != nil {
errs = append(errs, update("language", *params.Language))
}
for _, err := range errs {
if err != nil {
log.Print(err)
return false
}
}
if err := tx.Commit(); err != nil {
log.Print(err)
return false
}
return true
}

View File

@@ -0,0 +1,34 @@
package postgres
import (
"database/sql"
"log"
_ "github.com/lib/pq"
)
type PostgresStorage struct {
db *sql.DB
}
func New(connStr string) (*PostgresStorage, error) {
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if err := migrate(db); err != nil {
return nil, err
}
log.Print("connected to postgres")
return &PostgresStorage{db: db}, nil
}
func (s *PostgresStorage) Close() error {
return s.db.Close()
}

View File

@@ -1,94 +0,0 @@
package storage
import (
"encoding/json"
"log"
)
func settingsDefaults() map[string]interface{} {
return map[string]interface{}{
"filter": "",
"feed": "",
"feed_list_width": 300,
"item_list_width": 300,
"sort_newest_first": true,
"theme_name": "light",
"theme_font": "",
"theme_size": 1,
"refresh_rate": 0,
}
}
func (s *Storage) GetSettingsValue(key string) interface{} {
row := s.db.QueryRow(`select val from settings where key=?`, key)
if row == nil {
return settingsDefaults()[key]
}
var val []byte
row.Scan(&val)
if len(val) == 0 {
return nil
}
var valDecoded interface{}
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
log.Print(err)
return nil
}
return valDecoded
}
func (s *Storage) GetSettingsValueInt64(key string) int64 {
val := s.GetSettingsValue(key)
if val != nil {
if fval, ok := val.(float64); ok {
return int64(fval)
}
}
return 0
}
func (s *Storage) GetSettings() map[string]interface{} {
result := settingsDefaults()
rows, err := s.db.Query(`select key, val from settings;`)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var key string
var val []byte
var valDecoded interface{}
rows.Scan(&key, &val)
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
log.Print(err)
continue
}
result[key] = valDecoded
}
return result
}
func (s *Storage) UpdateSettings(kv map[string]interface{}) bool {
defaults := settingsDefaults()
for key, val := range kv {
if defaults[key] == nil {
continue
}
valEncoded, err := json.Marshal(val)
if err != nil {
log.Print(err)
return false
}
_, err = s.db.Exec(`
insert into settings (key, val) values (?, ?)
on conflict (key) do update set val=?`,
key, valEncoded, valEncoded,
)
if err != nil {
log.Print(err)
return false
}
}
return true
}

133
src/storage/sqlite/feed.go Normal file
View File

@@ -0,0 +1,133 @@
package sqlite
import (
"database/sql"
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *SQLiteStorage) CreateFeed(params model.CreateFeedParams) *model.Feed {
title := params.Title
if title == "" {
title = params.FeedLink
}
row := s.db.QueryRow(`
insert into feeds (title, description, link, feed_link, folder_id)
values (:title, :description, :link, :feed_link, :folder_id)
on conflict (feed_link) do update set folder_id = :folder_id
returning id`,
sql.Named("title", title),
sql.Named("description", params.Description),
sql.Named("link", params.Link),
sql.Named("feed_link", params.FeedLink),
sql.Named("folder_id", params.FolderID),
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &model.Feed{
Id: id,
Title: title,
Description: params.Description,
Link: params.Link,
FeedLink: params.FeedLink,
FolderId: params.FolderID,
}
}
func (s *SQLiteStorage) DeleteFeed(feedId int64) bool {
result, err := s.db.Exec(`delete from feeds where id = :id`, sql.Named("id", feedId))
if err != nil {
log.Print(err)
return false
}
nrows, err := result.RowsAffected()
if err != nil {
if err != sql.ErrNoRows {
log.Print(err)
}
return false
}
return nrows == 1
}
func (s *SQLiteStorage) UpdateFeed(feedId int64, params model.UpdateFeedParams) (bool, error) {
_, err := s.db.Exec(`
update feeds set
title = coalesce(:title, title),
feed_link = coalesce(:feed_link, feed_link),
folder_id = case when :update_folder_id then :folder_id else folder_id end,
icon = case when :update_icon then :icon else icon end
where id = :id
`,
sql.Named("id", feedId),
sql.Named("title", params.Title),
sql.Named("feed_link", params.FeedLink),
sql.Named("update_folder_id", params.FolderID.Set),
sql.Named("folder_id", params.FolderID.Value),
sql.Named("update_icon", params.Icon.Set),
sql.Named("icon", params.Icon.Value),
)
if err != nil {
log.Print(err)
return false, err
}
return true, nil
}
func (s *SQLiteStorage) ListFeeds() []model.Feed {
result := make([]model.Feed, 0)
rows, err := s.db.Query(`
select id, folder_id, title, description, link, feed_link,
ifnull(length(icon), 0) > 0 as has_icon
from feeds
order by title collate nocase
`)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var f model.Feed
err = rows.Scan(
&f.Id,
&f.FolderId,
&f.Title,
&f.Description,
&f.Link,
&f.FeedLink,
&f.HasIcon,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}
func (s *SQLiteStorage) GetFeed(id int64) *model.Feed {
var f model.Feed
err := s.db.QueryRow(`
select
id, folder_id, title, link, feed_link,
icon, ifnull(icon, '') != '' as has_icon
from feeds where id = :id
`, sql.Named("id", id)).Scan(
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
&f.Icon, &f.HasIcon,
)
if err != nil {
if err != sql.ErrNoRows {
log.Print(err)
}
return nil
}
return &f
}

View File

@@ -0,0 +1,105 @@
package sqlite
import (
"database/sql"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *SQLiteStorage) ListFeedStates() ([]model.FeedState, error) {
rows, err := s.db.Query(`
select
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
from feed_states
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := make([]model.FeedState, 0)
for rows.Next() {
var state model.FeedState
err := rows.Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastError,
&state.HTTPLastModified,
&state.HTTPEtag,
)
if err != nil {
return nil, err
}
states = append(states, state)
}
return states, nil
}
func (s *SQLiteStorage) GetFeedState(feedID int64) (*model.FeedState, error) {
var state model.FeedState
err := s.db.QueryRow(`
select
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
from feed_states where feed_id = :id
`, sql.Named("id", feedID)).Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastError,
&state.HTTPLastModified,
&state.HTTPEtag,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &state, nil
}
func (s *SQLiteStorage) UpdateFeedState(feedID int64, params model.UpdateFeedStateParams) (bool, error) {
lastError := params.LastError
if lastError != nil && *lastError == "" {
lastError = nil
}
_, err := s.db.Exec(`
insert into feed_states (
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
)
values (
:id
, coalesce(:last_refreshed, 0)
, coalesce(:last_error, '')
, coalesce(:http_lmod, '')
, coalesce(:http_etag, '')
)
on conflict (feed_id) do update set
last_refreshed = coalesce(:last_refreshed, last_refreshed),
last_error = coalesce(:last_error, last_error),
http_lmod = coalesce(:http_lmod, http_lmod),
http_etag = coalesce(:http_etag, http_etag)
`,
sql.Named("id", feedID),
sql.Named("last_refreshed", params.LastRefreshed),
sql.Named("last_error", params.LastError),
sql.Named("http_lmod", params.HTTPLastModified),
sql.Named("http_etag", params.HTTPEtag),
)
if err != nil {
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,76 @@
package sqlite
import (
"database/sql"
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *SQLiteStorage) CreateFolder(title string) *model.Folder {
expanded := true
row := s.db.QueryRow(`
insert into folders (title, is_expanded) values (:title, :is_expanded)
on conflict (title) do update set title = :title
returning id`,
sql.Named("title", title),
sql.Named("is_expanded", expanded),
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &model.Folder{Id: id, Title: title, IsExpanded: expanded}
}
func (s *SQLiteStorage) DeleteFolder(folderId int64) bool {
_, err := s.db.Exec(`delete from folders where id = :id`, sql.Named("id", folderId))
if err != nil {
log.Print(err)
}
return err == nil
}
func (s *SQLiteStorage) UpdateFolder(folderId int64, params model.UpdateFolderParams) (bool, error) {
_, err := s.db.Exec(`
update folders set
title = coalesce(:title, title),
is_expanded = coalesce(:is_expanded, is_expanded)
where id = :id
`,
sql.Named("id", folderId),
sql.Named("title", params.Title),
sql.Named("is_expanded", params.IsExpanded),
)
if err != nil {
log.Print(err)
return false, err
}
return true, nil
}
func (s *SQLiteStorage) ListFolders() []model.Folder {
result := make([]model.Folder, 0)
rows, err := s.db.Query(`
select id, title, is_expanded
from folders
order by title collate nocase
`)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var f model.Folder
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}

365
src/storage/sqlite/item.go Normal file
View File

@@ -0,0 +1,365 @@
package sqlite
import (
"cmp"
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"log"
"slices"
"strings"
"time"
"github.com/nkanaev/yarr/src/storage/model"
)
type MediaLinks model.MediaLinks
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)
}
func (s *SQLiteStorage) CreateItems(items []model.Item) bool {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
now := time.Now().UTC()
slices.SortStableFunc(items, func(a, b model.Item) int {
sa := a.Date.Format(time.RFC3339) + "::" + a.GUID
sb := b.Date.Format(time.RFC3339) + "::" + b.GUID
return cmp.Compare(sa, sb)
})
for _, item := range items {
_, err = tx.Exec(`
insert into items (
guid, feed_id, title, link, date,
content, media_links,
date_arrived, last_arrived, status
)
values (
:guid, :feed_id, :title, :link, strftime('%Y-%m-%d %H:%M:%f', :date),
:content, :media_links,
:date_arrived, :last_arrived, :status
)
on conflict (feed_id, guid) do update set
last_arrived = :last_arrived`,
sql.Named("guid", item.GUID),
sql.Named("feed_id", item.FeedId),
sql.Named("title", item.Title),
sql.Named("link", item.Link),
sql.Named("date", item.Date),
sql.Named("content", item.Content),
sql.Named("media_links", MediaLinks(item.MediaLinks)),
sql.Named("date_arrived", now),
sql.Named("last_arrived", now),
sql.Named("status", item.Status),
)
if err != nil {
log.Print(err)
if err = tx.Rollback(); err != nil {
log.Print(err)
return false
}
return false
}
}
if err = tx.Commit(); err != nil {
log.Print(err)
return false
}
return true
}
func listQueryPredicate(filter model.ItemFilter, newestFirst bool) (string, []any) {
cond := make([]string, 0)
args := make([]any, 0)
if filter.FolderID != nil {
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = :folder_id)")
args = append(args, sql.Named("folder_id", *filter.FolderID))
}
if filter.FeedID != nil {
cond = append(cond, "i.feed_id = :feed_id")
args = append(args, sql.Named("feed_id", *filter.FeedID))
}
if filter.Status != nil {
cond = append(cond, "i.status = :status")
args = append(args, sql.Named("status", *filter.Status))
}
if filter.Search != nil {
words := strings.Fields(*filter.Search)
terms := make([]string, len(words))
for idx, word := range words {
terms[idx] = word + "*"
}
cond = append(
cond,
"i.id in (select rowid as id from search where search match :search)",
)
args = append(args, sql.Named("search", strings.Join(terms, " ")))
}
if filter.After != nil {
compare := ">"
if newestFirst {
compare = "<"
}
cond = append(
cond,
fmt.Sprintf(
"(i.date, i.id) %s (select date, id from items where id = :after_id)",
compare,
),
)
args = append(args, sql.Named("after_id", *filter.After))
}
if filter.IDs != nil && len(*filter.IDs) > 0 {
qmarks := make([]string, len(*filter.IDs))
for i, id := range *filter.IDs {
name := fmt.Sprintf("id%d", i)
qmarks[i] = ":" + name
args = append(args, sql.Named(name, id))
}
cond = append(cond, "i.id in ("+strings.Join(qmarks, ",")+")")
}
if filter.SinceID != nil {
cond = append(cond, "i.id > :since_id")
args = append(args, sql.Named("since_id", filter.SinceID))
}
if filter.MaxID != nil {
cond = append(cond, "i.id < :max_id")
args = append(args, sql.Named("max_id", filter.MaxID))
}
if filter.Before != nil {
cond = append(cond, "i.date < :before")
args = append(args, sql.Named("before", filter.Before))
}
predicate := "1"
if len(cond) > 0 {
predicate = strings.Join(cond, " and ")
}
return predicate, args
}
func (s *SQLiteStorage) CountItems() int {
var count int
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
if err != nil {
log.Print(err)
return 0
}
return count
}
func (s *SQLiteStorage) ListItems(
filter model.ItemFilter,
limit int,
newestFirst bool,
withContent bool,
) []model.Item {
predicate, args := listQueryPredicate(filter, newestFirst)
result := make([]model.Item, 0)
order := "date desc, id desc"
if !newestFirst {
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(`
select %s
from items i
where %s
order by %s
limit %d
`, selectCols, predicate, order, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
var x model.Item
err = rows.Scan(
&x.Id, &x.GUID, &x.FeedId,
&x.Title, &x.Link, &x.Date,
&x.Status, (*MediaLinks)(&x.MediaLinks), &x.Content,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, x)
}
return result
}
func (s *SQLiteStorage) GetItem(id int64) *model.Item {
i := &model.Item{}
err := s.db.QueryRow(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.media_links
from items i
where i.id = :id
`, sql.Named("id", id)).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, (*MediaLinks)(&i.MediaLinks),
)
if err != nil {
log.Print(err)
return nil
}
return i
}
func (s *SQLiteStorage) UpdateItem(id int64, params model.UpdateItemParams) bool {
sets := make([]string, 0)
args := make([]any, 0)
if params.Title != nil {
sets = append(sets, "title = :title")
args = append(args, sql.Named("title", *params.Title))
}
if params.Status != nil {
sets = append(sets, "status = :status")
args = append(args, sql.Named("status", *params.Status))
}
if params.LastArrived != nil {
sets = append(sets, "last_arrived = :last_arrived")
args = append(args, sql.Named("last_arrived", *params.LastArrived))
}
if len(sets) == 0 {
return true
}
args = append(args, sql.Named("id", id))
query := fmt.Sprintf("update items set %s where id = :id", strings.Join(sets, ", "))
_, err := s.db.Exec(query, args...)
return err == nil
}
func (s *SQLiteStorage) DeleteItem(id int64) bool {
_, err := s.db.Exec(`delete from items where id = :id`, sql.Named("id", id))
return err == nil
}
func (s *SQLiteStorage) UpdateItemStatus(item_id int64, status model.ItemStatus) bool {
_, err := s.db.Exec(`update items set status = :status where id = :id`,
sql.Named("status", status),
sql.Named("id", item_id),
)
return err == nil
}
func (s *SQLiteStorage) MarkItemsRead(filter model.MarkFilter) bool {
predicate, args := listQueryPredicate(model.ItemFilter{
FolderID: filter.FolderID,
FeedID: filter.FeedID,
Before: filter.Before,
}, false)
query := fmt.Sprintf(`
update items as i set status = %d
where %s and i.status != %d
`, model.READ, predicate, model.STARRED)
_, err := s.db.Exec(query, args...)
if err != nil {
log.Print(err)
}
return err == nil
}
func (s *SQLiteStorage) FeedStats() []model.FeedStat {
result := make([]model.FeedStat, 0)
rows, err := s.db.Query(fmt.Sprintf(`
select
feed_id,
sum(case status when %d then 1 else 0 end),
sum(case status when %d then 1 else 0 end)
from items
group by feed_id
`, model.UNREAD, model.STARRED))
if err != nil {
log.Print(err)
return result
}
for rows.Next() {
stat := model.FeedStat{}
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
result = append(result, stat)
}
return result
}
var (
itemsKeepSize = 50
itemsKeepDays = 90
)
// Delete old articles from the database to cleanup space.
//
// The rules:
// - Never delete starred entries.
// - Keep at least 50 latest items for each feed.
// - Delete entries older than 90 days relative to the latest arrived item in the same feed.
func (s *SQLiteStorage) DeleteOldItems() {
result, err := s.db.Exec(`
delete from items
where id in (
select id
from (
select
id,
row_number() over (partition by feed_id order by date desc) as rn,
last_arrived,
max(last_arrived) over (partition by feed_id) as max_la
from items
where status != :starred_status
)
where rn > :keep_size
and last_arrived < datetime(max_la, :keep_days_limit)
)`,
sql.Named("starred_status", model.STARRED),
sql.Named("keep_size", itemsKeepSize),
sql.Named("keep_days_limit", fmt.Sprintf("-%d days", itemsKeepDays)),
)
if err != nil {
log.Print(err)
return
}
numDeleted, err := result.RowsAffected()
if err == nil && numDeleted > 0 {
log.Printf("Deleted %d old items", numDeleted)
if _, err := s.db.Exec("vacuum"); err != nil {
log.Print(err)
}
}
}

View File

@@ -1,4 +1,4 @@
package storage
package sqlite
import (
"database/sql"
@@ -18,6 +18,10 @@ var migrations = []func(*sql.Tx) error{
m08_normalize_datetime,
m09_change_item_index,
m10_add_item_medialinks,
m11_add_item_last_arrived,
m12_remove_feed_sizes,
m13_consolidate_feed_states,
m14_upgrade_fts5,
}
var maxVersion = int64(len(migrations))
@@ -290,7 +294,10 @@ func m08_normalize_datetime(tx *sql.Tx) error {
if err != nil {
return err
}
_, err = tx.Exec(`update items set date_arrived = ? where id = ?;`, dateArrived.UTC(), id)
_, err = tx.Exec(`update items set date_arrived = :date_arrived where id = :id;`,
sql.Named("date_arrived", dateArrived.UTC()),
sql.Named("id", id),
)
if err != nil {
return err
}
@@ -329,3 +336,87 @@ func m10_add_item_medialinks(tx *sql.Tx) error {
_, err := tx.Exec(sql)
return err
}
func m11_add_item_last_arrived(tx *sql.Tx) error {
sql := `alter table items add column last_arrived datetime`
_, err := tx.Exec(sql)
return err
}
func m12_remove_feed_sizes(tx *sql.Tx) error {
_, err := tx.Exec(`drop table if exists feed_sizes`)
return err
}
func m13_consolidate_feed_states(tx *sql.Tx) error {
sql := `
create table feed_states (
feed_id references feeds(id) on delete cascade unique
, last_refreshed datetime not null default 0
, last_error string not null default ''
, http_lmod string not null default ''
, http_etag string not null default ''
);
insert into feed_states (
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
)
select
f.id
, coalesce(h.last_refreshed, 0)
, coalesce(e.error, '')
, coalesce(h.last_modified, '')
, coalesce(h.etag, '')
from feeds f
left join http_states h on f.id = h.feed_id
left join feed_errors e on f.id = e.feed_id
where h.feed_id is not null or e.feed_id is not null;
drop table http_states;
drop table feed_errors;
`
_, err := tx.Exec(sql)
return err
}
func m14_upgrade_fts5(tx *sql.Tx) error {
sql := `
-- 1. Drop old FTS4 table and trigger
drop table if exists search;
drop trigger if exists del_item_search;
-- 2. Remove search_rowid from items
drop index if exists idx_item_search_rowid;
alter table items drop column search_rowid;
-- 3. Create FTS5 virtual table
create virtual table search using fts5(
title, content,
content='items',
content_rowid='id',
tokenize='unicode61'
);
-- 4. Create triggers for automatic FTS sync
create trigger items_ai after insert on items begin
insert into search(rowid, title, content) values (new.id, new.title, strip_html(new.content));
end;
create trigger items_ad after delete on items begin
insert into search(search, rowid, title, content) values('delete', old.id, old.title, strip_html(old.content));
end;
create trigger items_au after update on items begin
insert into search(search, rowid, title, content) values('delete', old.id, old.title, strip_html(old.content));
insert into search(rowid, title, content) values (new.id, new.title, strip_html(new.content));
end;
-- 5. Populate FTS5 table with existing data
insert into search(rowid, title, content) select id, title, strip_html(content) from items;
`
_, err := tx.Exec(sql)
return err
}

View File

@@ -0,0 +1,117 @@
package sqlite
import (
"database/sql"
"encoding/json"
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *SQLiteStorage) GetSettings() model.Settings {
result := model.SettingsDefault()
rows, err := s.db.Query(`select key, val from settings;`)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var key string
var val []byte
rows.Scan(&key, &val)
switch key {
case "filter":
json.Unmarshal(val, &result.Filter)
case "feed":
json.Unmarshal(val, &result.Feed)
case "feed_list_width":
json.Unmarshal(val, &result.FeedListWidth)
case "item_list_width":
json.Unmarshal(val, &result.ItemListWidth)
case "sort_newest_first":
json.Unmarshal(val, &result.SortNewestFirst)
case "theme_name":
json.Unmarshal(val, &result.ThemeName)
case "theme_font":
json.Unmarshal(val, &result.ThemeFont)
case "theme_size":
json.Unmarshal(val, &result.ThemeSize)
case "refresh_rate":
json.Unmarshal(val, &result.RefreshRate)
case "language":
json.Unmarshal(val, &result.Language)
}
}
return result
}
func (s *SQLiteStorage) UpdateSettings(params model.UpdateSettingsParams) bool {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
defer tx.Rollback()
update := func(key string, val any) error {
valEncoded, err := json.Marshal(val)
if err != nil {
return err
}
_, err = tx.Exec(`
insert into settings (key, val) values (:key, :val)
on conflict (key) do update set val=:val`,
sql.Named("key", key),
sql.Named("val", valEncoded),
)
return err
}
var errs []error
if params.Filter != nil {
errs = append(errs, update("filter", *params.Filter))
}
if params.Feed != nil {
errs = append(errs, update("feed", *params.Feed))
}
if params.FeedListWidth != nil {
errs = append(errs, update("feed_list_width", *params.FeedListWidth))
}
if params.ItemListWidth != nil {
errs = append(errs, update("item_list_width", *params.ItemListWidth))
}
if params.SortNewestFirst != nil {
errs = append(errs, update("sort_newest_first", *params.SortNewestFirst))
}
if params.ThemeName != nil {
errs = append(errs, update("theme_name", *params.ThemeName))
}
if params.ThemeFont != nil {
errs = append(errs, update("theme_font", *params.ThemeFont))
}
if params.ThemeSize != nil {
errs = append(errs, update("theme_size", *params.ThemeSize))
}
if params.RefreshRate != nil {
errs = append(errs, update("refresh_rate", *params.RefreshRate))
}
if params.Language != nil {
errs = append(errs, update("language", *params.Language))
}
for _, err := range errs {
if err != nil {
log.Print(err)
return false
}
}
if err := tx.Commit(); err != nil {
log.Print(err)
return false
}
return true
}

View File

@@ -0,0 +1,44 @@
package sqlite
import (
"database/sql"
"log"
"strings"
"github.com/mattn/go-sqlite3"
"github.com/nkanaev/yarr/src/content/htmlutil"
)
func init() {
sql.Register("sqlite3_yarr", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("strip_html", htmlutil.ExtractText, true)
},
})
}
type SQLiteStorage struct {
db *sql.DB
}
func New(path string) (*SQLiteStorage, 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_yarr", path)
if err != nil {
return nil, err
}
if err = migrate(db); err != nil {
return nil, err
}
return &SQLiteStorage{db: db}, nil
}
func (s *SQLiteStorage) Close() error {
return s.db.Close()
}

View File

@@ -1,31 +1,44 @@
package storage
import (
"database/sql"
"log"
"strings"
_ "github.com/mattn/go-sqlite3"
"github.com/nkanaev/yarr/src/storage/model"
"github.com/nkanaev/yarr/src/storage/postgres"
"github.com/nkanaev/yarr/src/storage/sqlite"
)
type Storage struct {
db *sql.DB
type Storage interface {
Close() error
CountItems() int
CreateFeed(params model.CreateFeedParams) *model.Feed
CreateFolder(title string) *model.Folder
CreateItems(items []model.Item) bool
DeleteFeed(feedId int64) bool
DeleteItem(id int64) bool
DeleteFolder(folderId int64) bool
DeleteOldItems()
FeedStats() []model.FeedStat
GetFeed(id int64) *model.Feed
GetFeedState(feedID int64) (*model.FeedState, error)
GetItem(id int64) *model.Item
GetSettings() model.Settings
ListFeedStates() ([]model.FeedState, error)
ListFeeds() []model.Feed
ListFolders() []model.Folder
ListItems(filter model.ItemFilter, limit int, newestFirst bool, withContent bool) []model.Item
MarkItemsRead(filter model.MarkFilter) bool
UpdateFeed(feedId int64, params model.UpdateFeedParams) (bool, error)
UpdateFeedState(feedID int64, params model.UpdateFeedStateParams) (bool, error)
UpdateFolder(folderId int64, params model.UpdateFolderParams) (bool, error)
UpdateItem(id int64, params model.UpdateItemParams) bool
UpdateItemStatus(item_id int64, status model.ItemStatus) bool
UpdateSettings(params model.UpdateSettingsParams) bool
}
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
func New(path string) (Storage, error) {
if strings.HasPrefix(path, "postgres://") || strings.HasPrefix(path, "postgresql://") {
return postgres.New(path)
}
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, err
}
if err = migrate(db); err != nil {
return nil, err
}
return &Storage{db: db}, nil
return sqlite.New(path)
}

View File

@@ -1,26 +0,0 @@
package storage
import (
"io"
"log"
"os"
"testing"
)
func testDB() *Storage {
log.SetOutput(io.Discard)
db, _ := New(":memory:")
log.SetOutput(os.Stderr)
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
return db
}
func TestStorage(t *testing.T) {
db, err := New(":memory:")
if err != nil {
t.Fatal(err)
}
if db == nil {
t.Fatal("no db")
}
}

View File

@@ -0,0 +1,153 @@
package tests
import (
"reflect"
"testing"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
func TestCreateFeed(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
feed1 := db.CreateFeed(model.CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example.com/feed.xml"})
if feed1 == nil || feed1.Id == 0 {
t.Fatal("expected feed")
}
feed2 := db.GetFeed(feed1.Id)
if feed2 == nil || !reflect.DeepEqual(feed1, feed2) {
t.Fatal("invalid feed")
}
})
}
func TestCreateFeedSameLink(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
feed1 := db.CreateFeed(model.CreateFeedParams{Title: "title", FeedLink: "http://example1.com/feed.xml"})
if feed1 == nil || feed1.Id == 0 {
t.Fatal("expected feed")
}
for range 10 {
db.CreateFeed(model.CreateFeedParams{Title: "title", FeedLink: "http://example2.com/feed.xml"})
}
feed2 := db.CreateFeed(model.CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example1.com/feed.xml"})
if feed1.Id != feed2.Id {
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
}
})
}
func TestReadFeed(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
if db.GetFeed(100500) != nil {
t.Fatal("cannot get nonexistent feed")
}
feed1 := db.CreateFeed(model.CreateFeedParams{Title: "feed 1", Link: "http://example1.com", FeedLink: "http://example1.com/feed.xml"})
feed2 := db.CreateFeed(model.CreateFeedParams{Title: "feed 2", Link: "http://example2.com", FeedLink: "http://example2.com/feed.xml"})
feeds := db.ListFeeds()
if !reflect.DeepEqual(feeds, []model.Feed{*feed1, *feed2}) {
t.Fatalf("invalid feed list: %#v", feeds)
}
})
}
func TestUpdateFeed(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
feed1 := db.CreateFeed(model.CreateFeedParams{Title: "feed 1", Link: "http://example1.com", FeedLink: "http://example1.com/feed.xml"})
folder := db.CreateFolder("test")
icon := []byte("icon")
title := "newtitle"
db.UpdateFeed(feed1.Id, model.UpdateFeedParams{
Title: &title,
FolderID: model.SetNullable(&folder.Id),
Icon: model.SetNullable(&icon),
})
feed2 := db.GetFeed(feed1.Id)
if feed2.Title != "newtitle" {
t.Error("invalid title")
}
if feed2.FolderId == nil || *feed2.FolderId != folder.Id {
t.Error("invalid folder")
}
if !feed2.HasIcon || string(*feed2.Icon) != "icon" {
t.Error("invalid icon")
}
})
}
func TestFeedStats(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
// empty
stats := db.FeedStats()
if len(stats) != 0 {
t.Errorf("expected 0 stats, got %d", len(stats))
}
scope := testItemsSetup(db)
stats = db.FeedStats()
statByFeed := make(map[int64]model.FeedStat)
for _, s := range stats {
statByFeed[s.FeedId] = s
}
for _, tc := range []struct {
feedID int64
unread int64
starred int64
}{
{scope.feed11.Id, 1, 1},
{scope.feed12.Id, 1, 0},
{scope.feed21.Id, 0, 1},
{scope.feed01.Id, 1, 1},
} {
s, ok := statByFeed[tc.feedID]
if !ok {
t.Errorf("feed %d missing from stats", tc.feedID)
continue
}
if s.UnreadCount != tc.unread {
t.Errorf("feed %d unread: expected %d, got %d", tc.feedID, tc.unread, s.UnreadCount)
}
if s.StarredCount != tc.starred {
t.Errorf("feed %d starred: expected %d, got %d", tc.feedID, tc.starred, s.StarredCount)
}
}
// mark feed11 read, verify stats update
db.MarkItemsRead(model.MarkFilter{FeedID: &scope.feed11.Id})
stats = db.FeedStats()
statByFeed = make(map[int64]model.FeedStat)
for _, s := range stats {
statByFeed[s.FeedId] = s
}
if s := statByFeed[scope.feed11.Id]; s.UnreadCount != 0 {
t.Errorf("feed11 unread after mark-read: expected 0, got %d", s.UnreadCount)
}
if s := statByFeed[scope.feed11.Id]; s.StarredCount != 1 {
t.Errorf("feed11 starred after mark-read: expected 1, got %d", s.StarredCount)
}
})
}
func TestDeleteFeed(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
feed1 := db.CreateFeed(model.CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example.com/feed.xml"})
if db.DeleteFeed(100500) {
t.Error("cannot delete what does not exist")
}
if !db.DeleteFeed(feed1.Id) {
t.Fatal("did not delete existing feed")
}
if db.GetFeed(feed1.Id) != nil {
t.Fatal("feed still exists")
}
})
}

View File

@@ -0,0 +1,128 @@
package tests
import (
"testing"
"time"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
func TestUpdateFeedState_Full(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
f := s.CreateFeed(model.CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
now := time.Now().UTC().Truncate(time.Second)
errMsg := "error"
lmod := "today"
etag := "v1"
ok, err := s.UpdateFeedState(f.Id, model.UpdateFeedStateParams{
LastRefreshed: &now,
LastError: &errMsg,
HTTPLastModified: &lmod,
HTTPEtag: &etag,
})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("expected true")
}
state, err := s.GetFeedState(f.Id)
if err != nil {
t.Fatal(err)
}
if state == nil {
t.Fatal("expected state, got nil")
}
if !state.LastRefreshed.Equal(now) {
t.Errorf("expected %v, got %v", now, state.LastRefreshed)
}
if state.LastError != errMsg {
t.Errorf("expected %s, got %v", errMsg, state.LastError)
}
if state.HTTPLastModified != lmod {
t.Errorf("expected %s, got %s", lmod, state.HTTPLastModified)
}
if state.HTTPEtag != etag {
t.Errorf("expected %s, got %s", etag, state.HTTPEtag)
}
})
}
func TestUpdateFeedState_Partial(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
f := s.CreateFeed(model.CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
etag := "v1"
s.UpdateFeedState(f.Id, model.UpdateFeedStateParams{HTTPEtag: &etag})
newErr := "new error"
_, err := s.UpdateFeedState(f.Id, model.UpdateFeedStateParams{
LastError: &newErr,
})
if err != nil {
t.Fatal(err)
}
state, err := s.GetFeedState(f.Id)
if err != nil {
t.Fatal(err)
}
if state.LastError != newErr {
t.Errorf("expected %s, got %v", newErr, state.LastError)
}
if state.HTTPEtag != etag {
t.Errorf("etag should be unchanged, got %s", state.HTTPEtag)
}
})
}
func TestUpdateFeedState_ClearError(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
f := s.CreateFeed(model.CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
errMsg := "error"
s.UpdateFeedState(f.Id, model.UpdateFeedStateParams{LastError: &errMsg})
empty := ""
_, err := s.UpdateFeedState(f.Id, model.UpdateFeedStateParams{
LastError: &empty,
})
if err != nil {
t.Fatal(err)
}
state, err := s.GetFeedState(f.Id)
if err != nil {
t.Fatal(err)
}
if state.LastError != "" {
t.Errorf("expected empty error string, got %v", state.LastError)
}
})
}
func TestListFeedStates(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
f1 := s.CreateFeed(model.CreateFeedParams{Title: "F1", FeedLink: "L1"})
f2 := s.CreateFeed(model.CreateFeedParams{Title: "F2", FeedLink: "L2"})
errMsg := "fail"
s.UpdateFeedState(f1.Id, model.UpdateFeedStateParams{LastError: &errMsg})
s.UpdateFeedState(f2.Id, model.UpdateFeedStateParams{HTTPEtag: ptr("e")})
states, err := s.ListFeedStates()
if err != nil {
t.Fatal(err)
}
if len(states) != 2 {
t.Errorf("expected 2 states, got %d", len(states))
}
})
}
func ptr[T any](v T) *T {
return &v
}

View File

@@ -0,0 +1,132 @@
package tests
import (
"testing"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
func TestCreateFolder(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
folder := db.CreateFolder("test-folder")
if folder == nil || folder.Id == 0 {
t.Fatal("expected folder with id")
}
if folder.Title != "test-folder" {
t.Errorf("expected title 'test-folder', got %s", folder.Title)
}
if !folder.IsExpanded {
t.Error("expected folder to be expanded by default")
}
// upsert: same title returns existing folder
folder2 := db.CreateFolder("test-folder")
if folder2 == nil || folder2.Id != folder.Id {
t.Errorf("expected same folder id on upsert, got %d != %d", folder2.Id, folder.Id)
}
folders := db.ListFolders()
if len(folders) != 1 || folders[0].Id != folder.Id {
t.Errorf("expected folder in ListFolders")
}
})
}
func TestDeleteFolder(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
// delete non-existent returns true (err == nil)
if !db.DeleteFolder(99999) {
t.Error("expected true when deleting non-existent folder")
}
folder := db.CreateFolder("test")
if !db.DeleteFolder(folder.Id) {
t.Fatal("delete failed")
}
folders := db.ListFolders()
if len(folders) != 0 {
t.Errorf("expected 0 folders, got %d", len(folders))
}
// deleting again returns true
if !db.DeleteFolder(folder.Id) {
t.Error("expected true when deleting already-deleted folder")
}
})
}
func TestUpdateFolder(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
folder := db.CreateFolder("old title")
if folder.IsExpanded != true {
t.Fatal("expected folder to be expanded by default")
}
t.Run("rename only", func(t *testing.T) {
newTitle := "new title"
ok, err := db.UpdateFolder(folder.Id, model.UpdateFolderParams{
Title: &newTitle,
})
if !ok || err != nil {
t.Fatalf("UpdateFolder failed: %v", err)
}
folders := db.ListFolders()
if len(folders) != 1 || folders[0].Title != "new title" {
t.Errorf("expected title to be updated, got %s", folders[0].Title)
}
if folders[0].IsExpanded != true {
t.Error("expected expansion state to remain unchanged")
}
})
t.Run("toggle expanded only", func(t *testing.T) {
isExpanded := false
ok, err := db.UpdateFolder(folder.Id, model.UpdateFolderParams{
IsExpanded: &isExpanded,
})
if !ok || err != nil {
t.Fatalf("UpdateFolder failed: %v", err)
}
folders := db.ListFolders()
if len(folders) != 1 || folders[0].IsExpanded != false {
t.Errorf("expected is_expanded to be false, got %v", folders[0].IsExpanded)
}
if folders[0].Title != "new title" {
t.Error("expected title to remain unchanged")
}
})
t.Run("update both", func(t *testing.T) {
bothTitle := "both"
isExpanded := true
ok, err := db.UpdateFolder(folder.Id, model.UpdateFolderParams{
Title: &bothTitle,
IsExpanded: &isExpanded,
})
if !ok || err != nil {
t.Fatalf("UpdateFolder failed: %v", err)
}
folders := db.ListFolders()
if len(folders) != 1 || folders[0].Title != "both" || folders[0].IsExpanded != true {
t.Errorf("expected both to be updated, got title=%s expanded=%v", folders[0].Title, folders[0].IsExpanded)
}
})
t.Run("update none", func(t *testing.T) {
ok, err := db.UpdateFolder(folder.Id, model.UpdateFolderParams{})
if !ok || err != nil {
t.Fatalf("UpdateFolder failed: %v", err)
}
folders := db.ListFolders()
if len(folders) != 1 || folders[0].Title != "both" || folders[0].IsExpanded != true {
t.Errorf("expected no changes, got title=%s expanded=%v", folders[0].Title, folders[0].IsExpanded)
}
})
})
}

View File

@@ -0,0 +1,574 @@
package tests
import (
"fmt"
"maps"
"reflect"
"slices"
"strconv"
"testing"
"testing/synctest"
"time"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
/*
- folder1
- feed11
- item111 (unread)
- item112 (read)
- item113 (starred)
- feed12
- item121 (unread)
- item122 (read)
- folder2
- feed21
- item211 (read)
- item212 (starred)
- feed01
- item011 (unread)
- item012 (read)
- item013 (starred)
*/
type testItemScope struct {
feed11, feed12 *model.Feed
feed21, feed01 *model.Feed
folder1, folder2 *model.Folder
items map[string]model.Item
}
func MustGet[K comparable, V any](m map[K]V, key K) V {
value, ok := m[key]
if !ok {
panic(fmt.Sprintf("key %v not found in map", key))
}
return value
}
func testItemsSetup(db storage.Storage) testItemScope {
folder1 := db.CreateFolder("folder1")
folder2 := db.CreateFolder("folder2")
feed11 := db.CreateFeed(model.CreateFeedParams{Title: "feed11", FeedLink: "http://test.com/feed11.xml", FolderID: &folder1.Id})
feed12 := db.CreateFeed(model.CreateFeedParams{Title: "feed12", FeedLink: "http://test.com/feed12.xml", FolderID: &folder1.Id})
feed21 := db.CreateFeed(model.CreateFeedParams{Title: "feed21", FeedLink: "http://test.com/feed21.xml", FolderID: &folder2.Id})
feed01 := db.CreateFeed(model.CreateFeedParams{Title: "feed01", FeedLink: "http://test.com/feed01.xml"})
now := time.Now()
items := map[string]model.Item{
// feed11
"item111": {
GUID: "item111",
FeedId: feed11.Id,
Title: "title111",
Date: now.Add(time.Hour * 24 * 1),
},
"item112": {
GUID: "item112",
FeedId: feed11.Id,
Title: "title112",
Date: now.Add(time.Hour * 24 * 2),
Status: model.READ,
}, // read
"item113": {
GUID: "item113",
FeedId: feed11.Id,
Title: "title113",
Date: now.Add(time.Hour * 24 * 3),
Status: model.STARRED,
}, // starred
// feed12
"item121": {
GUID: "item121",
FeedId: feed12.Id,
Title: "title121",
Date: now.Add(time.Hour * 24 * 4),
},
"item122": {
GUID: "item122",
FeedId: feed12.Id,
Title: "title122",
Date: now.Add(time.Hour * 24 * 5),
Status: model.READ,
}, // read
// feed21
"item211": {
GUID: "item211",
FeedId: feed21.Id,
Title: "title211",
Date: now.Add(time.Hour * 24 * 6),
Status: model.READ,
}, // read
"item212": {
GUID: "item212",
FeedId: feed21.Id,
Title: "title212",
Date: now.Add(time.Hour * 24 * 7),
Status: model.STARRED,
}, // starred
// feed01
"item011": {
GUID: "item011",
FeedId: feed01.Id,
Title: "title011",
Date: now.Add(time.Hour * 24 * 8),
},
"item012": {
GUID: "item012",
FeedId: feed01.Id,
Title: "title012",
Date: now.Add(time.Hour * 24 * 9),
Status: model.READ,
}, // read
"item013": {
GUID: "item013",
FeedId: feed01.Id,
Title: "title013",
Date: now.Add(time.Hour * 24 * 10),
Status: model.STARRED,
}, // starred
}
db.CreateItems(slices.Collect(maps.Values(items)))
return testItemScope{
feed11: feed11,
feed12: feed12,
feed21: feed21,
feed01: feed01,
folder1: folder1,
folder2: folder2,
items: items,
}
}
func getItemGuids(items []model.Item) []string {
guids := make([]string, 0)
for _, item := range items {
guids = append(guids, item.GUID)
}
return guids
}
func TestListItems(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
scope := testItemsSetup(db)
// filter by folder_id
have := getItemGuids(db.ListItems(model.ItemFilter{FolderID: &scope.folder1.Id}, 10, false, false))
want := []string{"item111", "item112", "item113", "item121", "item122"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
have = getItemGuids(db.ListItems(model.ItemFilter{FolderID: &scope.folder2.Id}, 10, false, false))
want = []string{"item211", "item212"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// filter by feed_id
have = getItemGuids(db.ListItems(model.ItemFilter{FeedID: &scope.feed11.Id}, 10, false, false))
want = []string{"item111", "item112", "item113"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
have = getItemGuids(db.ListItems(model.ItemFilter{FeedID: &scope.feed01.Id}, 10, false, false))
want = []string{"item011", "item012", "item013"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// filter by status
var starred model.ItemStatus = model.STARRED
have = getItemGuids(db.ListItems(model.ItemFilter{Status: &starred}, 10, false, false))
want = []string{"item113", "item212", "item013"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
var unread model.ItemStatus = model.UNREAD
have = getItemGuids(db.ListItems(model.ItemFilter{Status: &unread}, 10, false, false))
want = []string{"item111", "item121", "item011"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// limit
have = getItemGuids(db.ListItems(model.ItemFilter{}, 2, false, false))
want = []string{"item111", "item112"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// filter by search
search1 := "title111"
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &search1}, 4, true, false))
want = []string{"item111"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// sort by date
have = getItemGuids(db.ListItems(model.ItemFilter{}, 4, true, false))
want = []string{"item013", "item012", "item011", "item212"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
})
}
func TestListItemsPaginated(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
testItemsSetup(db)
itemsByGUID := make(map[string]model.Item)
for _, item := range db.ListItems(model.ItemFilter{}, 1000, false, false) {
itemsByGUID[item.GUID] = item
}
item012 := MustGet(itemsByGUID, "item012")
item121 := MustGet(itemsByGUID, "item121")
// all, newest first
have := getItemGuids(db.ListItems(model.ItemFilter{After: &item012.Id}, 3, true, false))
want := []string{"item011", "item212", "item211"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// unread, newest first
unread := model.UNREAD
have = getItemGuids(
db.ListItems(model.ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false),
)
want = []string{"item011", "item121", "item111"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
// starred, oldest first
starred := model.STARRED
have = getItemGuids(
db.ListItems(model.ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false),
)
want = []string{"item212", "item013"}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
})
}
func TestMarkAllItemsRead(t *testing.T) {
var read model.ItemStatus = model.READ
dbtest(t, func(t *testing.T, db storage.Storage) {
testItemsSetup(db)
db.MarkItemsRead(model.MarkFilter{})
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
want := []string{
"item111", "item112", "item121", "item122",
"item211", "item011", "item012",
}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
})
}
func TestMarkItemsReadByFolder(t *testing.T) {
var read model.ItemStatus = model.READ
dbtest(t, func(t *testing.T, db storage.Storage) {
scope := testItemsSetup(db)
db.MarkItemsRead(model.MarkFilter{FolderID: &scope.folder1.Id})
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
want := []string{
"item111", "item112", "item121", "item122",
"item211", "item012",
}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
})
}
func TestMarkItemsReadByFeed(t *testing.T) {
var read model.ItemStatus = model.READ
dbtest(t, func(t *testing.T, db storage.Storage) {
scope := testItemsSetup(db)
db.MarkItemsRead(model.MarkFilter{FeedID: &scope.feed11.Id})
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
want := []string{
"item111", "item112", "item122",
"item211", "item012",
}
if !reflect.DeepEqual(have, want) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fail()
}
})
}
func TestDeleteOldItems(t *testing.T) {
t.Run("keeps at least 50 items", func(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
synctest.Test(t, func(t *testing.T) {
feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
now := time.Now()
items := make([]model.Item, 100)
for i := range 100 {
items[i] = model.Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Hour * 24)}
}
db.CreateItems(items)
// // Set 1 recent (latest), 99 old (100 days ago)
time.Sleep(100 * 24 * time.Hour)
db.CreateItems([]model.Item{items[99]})
db.DeleteOldItems()
remaining := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 1000, false, false)
if len(remaining) != 50 {
t.Errorf("expected 50 items, have %d", len(remaining))
}
})
})
})
t.Run("keeps all less than 90 days old", func(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
synctest.Test(t, func(t *testing.T) {
feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
now := time.Now()
items := make([]model.Item, 100)
for i := 0; i < 100; i++ {
items[i] = model.Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now}
}
db.CreateItems(items)
// Latest item at "now"
// All others at 80 days ago (keep)
time.Sleep(80 * 24 * time.Hour)
db.CreateItems([]model.Item{items[99]})
db.DeleteOldItems()
remaining := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 1000, false, false)
if len(remaining) != 100 {
t.Errorf("expected 100 items, have %d", len(remaining))
}
})
})
})
t.Run("keeps starred", func(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
synctest.Test(t, func(t *testing.T) {
feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
now := time.Now()
items := make([]model.Item, 100)
for i := 0; i < 100; i++ {
items[i] = model.Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
}
db.CreateItems(items)
// Set all to 100 days ago, except one recent
time.Sleep(100 * 24 * time.Hour)
db.CreateItems([]model.Item{items[99]})
// Star 10 old items that would otherwise be deleted (rn > 50 and old)
allItems := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 100, false, false)
for _, item := range allItems {
guid, _ := strconv.Atoi(item.GUID)
if guid < 10 {
db.UpdateItemStatus(item.Id, model.STARRED)
}
}
db.DeleteOldItems()
// 50 (limit) + 10 (starred) = 60 items should remain.
remaining := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 1000, false, false)
if len(remaining) != 60 {
t.Errorf("expected 60 items, have %d", len(remaining))
}
})
})
})
// })
}
func TestDeleteItem(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
feed := db.CreateFeed(model.CreateFeedParams{FeedLink: "http://test.com/feed.xml"})
db.CreateItems([]model.Item{{GUID: "i1", FeedId: feed.Id, Title: "item"}})
items := db.ListItems(model.ItemFilter{}, 10, false, false)
if len(items) != 1 {
t.Fatal("expected 1 item")
}
// delete non-existent returns true (err == nil)
if !db.DeleteItem(99999) {
t.Error("expected true when deleting non-existent item")
}
// delete existing
if !db.DeleteItem(items[0].Id) {
t.Fatal("delete failed")
}
items = db.ListItems(model.ItemFilter{}, 10, false, false)
if len(items) != 0 {
t.Errorf("expected 0 items, got %d", len(items))
}
})
}
func TestCountItems(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
if count := db.CountItems(); count != 0 {
t.Errorf("expected 0, got %d", count)
}
feed := db.CreateFeed(model.CreateFeedParams{FeedLink: "http://test.com/feed.xml"})
db.CreateItems([]model.Item{
{GUID: "i1", FeedId: feed.Id},
{GUID: "i2", FeedId: feed.Id},
{GUID: "i3", FeedId: feed.Id},
})
if count := db.CountItems(); count != 3 {
t.Errorf("expected 3, got %d", count)
}
items := db.ListItems(model.ItemFilter{}, 10, false, false)
db.DeleteItem(items[0].Id)
if count := db.CountItems(); count != 2 {
t.Errorf("expected 2, got %d", count)
}
})
}
func TestSearch(t *testing.T) {
dbtest(t, func(t *testing.T, db storage.Storage) {
feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
db.CreateItems([]model.Item{
{
GUID: "i1",
FeedId: feed.Id,
Title: "Hello World",
Content: "This is a <b>test</b> of the <i>emergency</i> broadcast system.",
},
{
GUID: "i2",
FeedId: feed.Id,
Title: "FTS5 Unicode",
Content: "Unicode support with characters like: Привет, 世界, 🚀",
},
{
GUID: "i3",
FeedId: feed.Id,
Title: "Hidden Tag",
Content: `<div class="secret-class">Don't find me by my class name</div>`,
},
})
itemsByGUID := make(map[string]model.Item)
for _, item := range db.ListItems(model.ItemFilter{}, 1000, false, false) {
itemsByGUID[item.GUID] = item
}
// 1. Basic search
s1 := "emergency"
have := getItemGuids(db.ListItems(model.ItemFilter{Search: &s1}, 10, true, false))
if !reflect.DeepEqual(have, []string{"i1"}) {
t.Errorf("basic search failed: expected [i1], got %v", have)
}
// 2. HTML stripping: Should find text, but NOT the tags
s2 := "test"
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s2}, 10, true, false))
if !reflect.DeepEqual(have, []string{"i1"}) {
t.Errorf("html text search failed: expected [i1], got %v", have)
}
s3 := "secret-class"
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s3}, 10, true, false))
if len(have) > 0 {
t.Errorf("html tag search should have failed but found: %v", have)
}
// 3. Multi-word (AND)
s4 := "broadcast system"
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s4}, 10, true, false))
if !reflect.DeepEqual(have, []string{"i1"}) {
t.Errorf("multi-word search failed: expected [i1], got %v", have)
}
// 4. Unicode
s5 := "Привет"
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s5}, 10, true, false))
if !reflect.DeepEqual(have, []string{"i2"}) {
t.Errorf("unicode search failed: expected [i2], got %v", have)
}
s6 := "世界"
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s6}, 10, true, false))
if !reflect.DeepEqual(have, []string{"i2"}) {
t.Errorf("unicode search (CJK) failed: expected [i2], got %v", have)
}
// 5. Trigger: Update
db.UpdateItem(MustGet(itemsByGUID, "i1").Id, model.UpdateItemParams{Title: ptr("Updated Title")})
s7 := "Updated"
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s7}, 10, true, false))
if !reflect.DeepEqual(have, []string{"i1"}) {
t.Errorf("update trigger failed: expected [i1], got %v", have)
}
// 6. Trigger: Delete
// db.db.Exec("delete from items where guid = 'i1'")
db.DeleteItem(MustGet(itemsByGUID, "i1").Id)
have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s7}, 10, true, false))
if len(have) > 0 {
t.Errorf("delete trigger failed: found deleted item: %v", have)
}
})
}

View File

@@ -0,0 +1,151 @@
package tests
import (
"reflect"
"strings"
"testing"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
func TestSettingsDefaults(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
settings := s.GetSettings()
defaults := model.SettingsDefault()
if !reflect.DeepEqual(settings, defaults) {
t.Errorf("expected defaults %+v, got %+v", defaults, settings)
}
})
}
func TestUpdateSettings(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
params := model.UpdateSettingsParams{
ThemeName: ptr("night"),
FeedListWidth: ptr(400),
RefreshRate: ptr(int64(15)),
}
if ok := s.UpdateSettings(params); !ok {
t.Fatal("UpdateSettings failed")
}
settings := s.GetSettings()
if settings.ThemeName != "night" {
t.Errorf("expected theme_name night, got %s", settings.ThemeName)
}
if settings.FeedListWidth != 400 {
t.Errorf("expected feed_list_width 400, got %d", settings.FeedListWidth)
}
if settings.RefreshRate != 15 {
t.Errorf("expected refresh_rate 15, got %d", settings.RefreshRate)
}
})
}
func TestGetSettings(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
s.UpdateSettings(model.UpdateSettingsParams{Language: ptr("fr")})
settings := s.GetSettings()
if settings.Language != "fr" {
t.Errorf("expected fr, got %v", settings.Language)
}
if settings.ThemeName != "light" {
t.Errorf("expected light, got %v", settings.ThemeName)
}
})
}
func TestSettingsExhaustive(t *testing.T) {
dbtest(t, func(t *testing.T, s storage.Storage) {
settingsType := reflect.TypeOf(model.Settings{})
paramsType := reflect.TypeOf(model.UpdateSettingsParams{})
settings := s.GetSettings()
m := settings.Map()
for i := 0; i < settingsType.NumField(); i++ {
field := settingsType.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" {
t.Errorf("Field %s missing json tag", field.Name)
continue
}
// json tags might have options like "name,omitempty", take only the first part
jsonKey := strings.Split(jsonTag, ",")[0]
// 1. Check Map()
if _, ok := m[jsonKey]; !ok {
t.Errorf("Key %q (from field %s) missing from Settings.Map()", jsonKey, field.Name)
}
// 2. Check UpdateSettingsParams
foundInParams := false
for j := 0; j < paramsType.NumField(); j++ {
pField := paramsType.Field(j)
pJsonTag := strings.Split(pField.Tag.Get("json"), ",")[0]
if pJsonTag == jsonKey {
foundInParams = true
// Also check it's a pointer
if pField.Type.Kind() != reflect.Ptr {
t.Errorf("Field %s in UpdateSettingsParams should be a pointer", pField.Name)
}
break
}
}
if !foundInParams {
t.Errorf("Key %q (from field %s) missing from UpdateSettingsParams", jsonKey, field.Name)
}
// 3. Test round-trip update
// We'll create a new UpdateSettingsParams and set ONLY this field
paramsValue := reflect.New(paramsType).Elem()
for j := 0; j < paramsType.NumField(); j++ {
pField := paramsType.Field(j)
pJsonTag := strings.Split(pField.Tag.Get("json"), ",")[0]
if pJsonTag == jsonKey {
// Create a new value of the underlying type
val := reflect.New(field.Type).Elem()
switch field.Type.Kind() {
case reflect.String:
val.SetString("test_" + jsonKey)
case reflect.Int, reflect.Int64:
val.SetInt(42)
case reflect.Bool:
val.SetBool(false)
}
paramsValue.Field(j).Set(val.Addr())
break
}
}
if ok := s.UpdateSettings(paramsValue.Interface().(model.UpdateSettingsParams)); !ok {
t.Errorf("UpdateSettings failed for %q", jsonKey)
}
updated := s.GetSettings()
updatedValue := reflect.ValueOf(updated).Field(i)
switch field.Type.Kind() {
case reflect.String:
if updatedValue.String() != "test_"+jsonKey {
t.Errorf("Round-trip failed for %q: expected %q, got %q (check UpdateSettings/GetSettings switch)", jsonKey, "test_"+jsonKey, updatedValue.String())
}
case reflect.Int, reflect.Int64:
if updatedValue.Int() != 42 {
t.Errorf("Round-trip failed for %q: expected 42, got %d (check UpdateSettings/GetSettings switch)", jsonKey, updatedValue.Int())
}
case reflect.Bool:
if updatedValue.Bool() != false {
t.Errorf("Round-trip failed for %q: expected false, got %v (check UpdateSettings/GetSettings switch)", jsonKey, updatedValue.Bool())
}
}
}
})
}

View File

@@ -0,0 +1,111 @@
package tests
import (
"context"
"crypto/sha256"
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
_ "github.com/lib/pq"
"github.com/nkanaev/yarr/src/storage"
)
func dbtest(t *testing.T, testcase func(t *testing.T, db storage.Storage)) {
t.Parallel()
testurls := map[string]string{
"sqlite": ":memory:",
}
if pgImage := os.Getenv("YARR_POSTGRES_TEST_IMAGE"); pgImage != "" {
dburl, cleanup := startPostgresContainer(t, pgImage)
t.Cleanup(cleanup)
testurls["postgres"] = dburl
} else if !testing.Short() {
t.Fatalf("YARR_POSTGRES_TEST_IMAGE not set; use -short to skip docker tests")
}
for testname, url := range testurls {
db, err := storage.New(url)
if err != nil {
t.Fatalf("failed to init storage for %s: %v", url, err)
}
t.Run(testname, func(t *testing.T) {
testcase(t, db)
})
}
}
func startPostgresContainer(t *testing.T, image string) (string, func()) {
// database credentials
dbUser := "testuser"
dbPass := "password"
dbName := "yarrtest"
// generate unique container name
testHash := sha256.Sum256([]byte(t.Name()))
containerName := fmt.Sprintf("yarr-test-pg-%x-%d", testHash[:8], time.Now().UnixNano())
cmd := exec.Command(
"docker", "run", "-d", "--rm",
"--name", containerName,
"-p", "0:5432",
"-e", "POSTGRES_USER="+dbUser,
"-e", "POSTGRES_PASSWORD="+dbPass,
"-e", "POSTGRES_DB="+dbName,
image,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("failed to start postgres container: %v\n%s", err, string(out))
}
// retrieve the host port assigned by docker
portCmd := exec.Command("docker", "port", containerName, "5432/tcp")
portOut, err := portCmd.Output()
if err != nil {
t.Fatalf("failed to get container port: %v", err)
}
parts := strings.Split(strings.TrimSpace(string(portOut)), ":")
dbPort := parts[len(parts)-1]
// build connection string
pgUrl := fmt.Sprintf(
"postgres://%s:%s@localhost:%s/%s?sslmode=disable",
dbUser,
dbPass,
dbPort,
dbName,
)
// wait up to 15 seconds for the container to accept connections
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
db, err := sql.Open("postgres", pgUrl)
if err != nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
err = db.PingContext(ctx)
cancel()
db.Close()
if err == nil {
goto ready
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("timed out waiting for postgres container to be ready")
ready:
// return connection url and a cleanup function that stops the container
return pgUrl, func() {
stop := exec.Command("docker", "stop", containerName)
if err := stop.Run(); err != nil {
t.Logf("failed to stop container %s: %v", containerName, err)
}
}
}

View File

@@ -1,12 +0,0 @@
taken from:
repo:
https://github.com/getlantern/systray
hash:
2c0986dda9aea361e925f90e848d9036be7b5367
changes:
-removed `getlantern/golog` dependency
-prevent from compiling in linux

View File

@@ -1,120 +0,0 @@
systray is a cross-platform Go library to place an icon and menu in the notification area.
## Features
* Supported on Windows, macOS, and Linux
* Menu items can be checked and/or disabled
* Methods may be called from any Goroutine
## API
```go
func main() {
systray.Run(onReady, onExit)
}
func onReady() {
systray.SetIcon(icon.Data)
systray.SetTitle("Awesome App")
systray.SetTooltip("Pretty awesome超级棒")
mQuit := systray.AddMenuItem("Quit", "Quit the whole app")
// Sets the icon of a menu item. Only available on Mac and Windows.
mQuit.SetIcon(icon.Data)
}
func onExit() {
// clean up here
}
```
See [full API](https://pkg.go.dev/github.com/getlantern/systray?tab=doc) as well as [CHANGELOG](https://github.com/getlantern/systray/tree/master/CHANGELOG.md).
## Try the example app!
Have go v1.12+ or higher installed? Here's an example to get started on macOS:
```sh
git clone https://github.com/getlantern/systray
cd example
env GO111MODULE=on go build
./example
```
On Windows, you should build like this:
```
env GO111MODULE=on go build -ldflags "-H=windowsgui"
```
The following text will then appear on the console:
```sh
go: finding github.com/skratchdot/open-golang latest
go: finding github.com/getlantern/systray latest
go: finding github.com/getlantern/golog latest
```
Now look for *Awesome App* in your menu bar!
![Awesome App screenshot](example/screenshot.png)
## The Webview example
The code under `webview_example` is to demostrate how it can co-exist with other UI elements. Note that the example doesn't work on macOS versions older than 10.15 Catalina.
## Platform notes
### Linux
* Building apps requires gcc as well as the `gtk3` and `libappindicator3` development headers to be installed. For Debian or Ubuntu, you may install these using:
```sh
sudo apt-get install gcc libgtk-3-dev libappindicator3-dev
```
On Linux Mint, `libxapp-dev` is also required .
To build `webview_example`, you also need to install `libwebkit2gtk-4.0-dev` and remove `webview_example/rsrc.syso` which is required on Windows.
### Windows
* To avoid opening a console at application startup, use these compile flags:
```sh
go build -ldflags -H=windowsgui
```
### macOS
On macOS, you will need to create an application bundle to wrap the binary; simply folders with the following minimal structure and assets:
```
SystrayApp.app/
Contents/
Info.plist
MacOS/
go-executable
Resources/
SystrayApp.icns
```
When running as an app bundle, you may want to add one or both of the following to your Info.plist:
```xml
<!-- avoid having a blurry icon and text -->
<key>NSHighResolutionCapable</key>
<string>True</string>
<!-- avoid showing the app on the Dock -->
<key>LSUIElement</key>
<string>1</string>
```
Consult the [Official Apple Documentation here](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1).
## Credits
- https://github.com/xilp/systray
- https://github.com/cratonica/trayhost

View File

@@ -1,268 +0,0 @@
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>
#include <libappindicator/app-indicator.h>
#include "systray.h"
static AppIndicator *global_app_indicator;
static GtkWidget *global_tray_menu = NULL;
static GList *global_menu_items = NULL;
static char temp_file_name[PATH_MAX] = "";
typedef struct {
GtkWidget *menu_item;
int menu_id;
long signalHandlerId;
} MenuItemNode;
typedef struct {
int menu_id;
int parent_menu_id;
char* title;
char* tooltip;
short disabled;
short checked;
short isCheckable;
} MenuItemInfo;
void registerSystray(void) {
gtk_init(0, NULL);
global_app_indicator = app_indicator_new("systray", "", APP_INDICATOR_CATEGORY_APPLICATION_STATUS);
app_indicator_set_status(global_app_indicator, APP_INDICATOR_STATUS_ACTIVE);
global_tray_menu = gtk_menu_new();
app_indicator_set_menu(global_app_indicator, GTK_MENU(global_tray_menu));
systray_ready();
}
int nativeLoop(void) {
gtk_main();
systray_on_exit();
return 0;
}
void _unlink_temp_file() {
if (strlen(temp_file_name) != 0) {
int ret = unlink(temp_file_name);
if (ret == -1) {
printf("failed to remove temp icon file %s: %s\n", temp_file_name, strerror(errno));
}
temp_file_name[0] = '\0';
}
}
// runs in main thread, should always return FALSE to prevent gtk to execute it again
gboolean do_set_icon(gpointer data) {
_unlink_temp_file();
char *tmpdir = getenv("TMPDIR");
if (NULL == tmpdir) {
tmpdir = "/tmp";
}
strncpy(temp_file_name, tmpdir, PATH_MAX-1);
strncat(temp_file_name, "/systray_XXXXXX", PATH_MAX-1);
temp_file_name[PATH_MAX-1] = '\0';
GBytes* bytes = (GBytes*)data;
int fd = mkstemp(temp_file_name);
if (fd == -1) {
printf("failed to create temp icon file %s: %s\n", temp_file_name, strerror(errno));
return FALSE;
}
gsize size = 0;
gconstpointer icon_data = g_bytes_get_data(bytes, &size);
ssize_t written = write(fd, icon_data, size);
close(fd);
if(written != size) {
printf("failed to write temp icon file %s: %s\n", temp_file_name, strerror(errno));
return FALSE;
}
app_indicator_set_icon_full(global_app_indicator, temp_file_name, "");
app_indicator_set_attention_icon_full(global_app_indicator, temp_file_name, "");
g_bytes_unref(bytes);
return FALSE;
}
void _systray_menu_item_selected(int *id) {
systray_menu_item_selected(*id);
}
GtkMenuItem* find_menu_by_id(int id) {
GList* it;
for(it = global_menu_items; it != NULL; it = it->next) {
MenuItemNode* item = (MenuItemNode*)(it->data);
if(item->menu_id == id) {
return GTK_MENU_ITEM(item->menu_item);
}
}
return NULL;
}
// runs in main thread, should always return FALSE to prevent gtk to execute it again
gboolean do_add_or_update_menu_item(gpointer data) {
MenuItemInfo *mii = (MenuItemInfo*)data;
GList* it;
for(it = global_menu_items; it != NULL; it = it->next) {
MenuItemNode* item = (MenuItemNode*)(it->data);
if(item->menu_id == mii->menu_id) {
gtk_menu_item_set_label(GTK_MENU_ITEM(item->menu_item), mii->title);
if (mii->isCheckable) {
// We need to block the "activate" event, to emulate the same behaviour as in the windows version
// A Check/Uncheck does change the checkbox, but does not trigger the checkbox menuItem channel
g_signal_handler_block(GTK_CHECK_MENU_ITEM(item->menu_item), item->signalHandlerId);
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item->menu_item), mii->checked == 1);
g_signal_handler_unblock(GTK_CHECK_MENU_ITEM(item->menu_item), item->signalHandlerId);
}
break;
}
}
// menu id doesn't exist, add new item
if(it == NULL) {
GtkWidget *menu_item;
if (mii->isCheckable) {
menu_item = gtk_check_menu_item_new_with_label(mii->title);
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(menu_item), mii->checked == 1);
} else {
menu_item = gtk_menu_item_new_with_label(mii->title);
}
int *id = malloc(sizeof(int));
*id = mii->menu_id;
long signalHandlerId = g_signal_connect_swapped(
G_OBJECT(menu_item),
"activate",
G_CALLBACK(_systray_menu_item_selected),
id
);
if (mii->parent_menu_id == 0) {
gtk_menu_shell_append(GTK_MENU_SHELL(global_tray_menu), menu_item);
} else {
GtkMenuItem* parentMenuItem = find_menu_by_id(mii->parent_menu_id);
GtkWidget* parentMenu = gtk_menu_item_get_submenu(parentMenuItem);
if(parentMenu == NULL) {
parentMenu = gtk_menu_new();
gtk_menu_item_set_submenu(parentMenuItem, parentMenu);
}
gtk_menu_shell_append(GTK_MENU_SHELL(parentMenu), menu_item);
}
MenuItemNode* new_item = malloc(sizeof(MenuItemNode));
new_item->menu_id = mii->menu_id;
new_item->signalHandlerId = signalHandlerId;
new_item->menu_item = menu_item;
GList* new_node = malloc(sizeof(GList));
new_node->data = new_item;
new_node->next = global_menu_items;
if(global_menu_items != NULL) {
global_menu_items->prev = new_node;
}
global_menu_items = new_node;
it = new_node;
}
GtkWidget* menu_item = GTK_WIDGET(((MenuItemNode*)(it->data))->menu_item);
gtk_widget_set_sensitive(menu_item, mii->disabled != 1);
gtk_widget_show(menu_item);
free(mii->title);
free(mii->tooltip);
free(mii);
return FALSE;
}
gboolean do_add_separator(gpointer data) {
GtkWidget *separator = gtk_separator_menu_item_new();
gtk_menu_shell_append(GTK_MENU_SHELL(global_tray_menu), separator);
gtk_widget_show(separator);
return FALSE;
}
// runs in main thread, should always return FALSE to prevent gtk to execute it again
gboolean do_hide_menu_item(gpointer data) {
MenuItemInfo *mii = (MenuItemInfo*)data;
GList* it;
for(it = global_menu_items; it != NULL; it = it->next) {
MenuItemNode* item = (MenuItemNode*)(it->data);
if(item->menu_id == mii->menu_id){
gtk_widget_hide(GTK_WIDGET(item->menu_item));
break;
}
}
return FALSE;
}
// runs in main thread, should always return FALSE to prevent gtk to execute it again
gboolean do_show_menu_item(gpointer data) {
MenuItemInfo *mii = (MenuItemInfo*)data;
GList* it;
for(it = global_menu_items; it != NULL; it = it->next) {
MenuItemNode* item = (MenuItemNode*)(it->data);
if(item->menu_id == mii->menu_id){
gtk_widget_show(GTK_WIDGET(item->menu_item));
break;
}
}
return FALSE;
}
// runs in main thread, should always return FALSE to prevent gtk to execute it again
gboolean do_quit(gpointer data) {
_unlink_temp_file();
// app indicator doesn't provide a way to remove it, hide it as a workaround
app_indicator_set_status(global_app_indicator, APP_INDICATOR_STATUS_PASSIVE);
gtk_main_quit();
return FALSE;
}
void setIcon(const char* iconBytes, int length, bool template) {
GBytes* bytes = g_bytes_new_static(iconBytes, length);
g_idle_add(do_set_icon, bytes);
}
void setTitle(char* ctitle) {
app_indicator_set_label(global_app_indicator, ctitle, "");
free(ctitle);
}
void setTooltip(char* ctooltip) {
free(ctooltip);
}
void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template) {
}
void add_or_update_menu_item(int menu_id, int parent_menu_id, char* title, char* tooltip, short disabled, short checked, short isCheckable) {
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
mii->menu_id = menu_id;
mii->parent_menu_id = parent_menu_id;
mii->title = title;
mii->tooltip = tooltip;
mii->disabled = disabled;
mii->checked = checked;
mii->isCheckable = isCheckable;
g_idle_add(do_add_or_update_menu_item, mii);
}
void add_separator(int menu_id) {
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
mii->menu_id = menu_id;
g_idle_add(do_add_separator, mii);
}
void hide_menu_item(int menu_id) {
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
mii->menu_id = menu_id;
g_idle_add(do_hide_menu_item, mii);
}
void show_menu_item(int menu_id) {
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
mii->menu_id = menu_id;
g_idle_add(do_show_menu_item, mii);
}
void quit() {
g_idle_add(do_quit, NULL);
}

View File

@@ -1,38 +0,0 @@
package systray
/*
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Cocoa -framework WebKit
#include "systray.h"
*/
import "C"
import (
"unsafe"
)
// SetTemplateIcon sets the systray icon as a template icon (on Mac), falling back
// to a regular icon on other platforms.
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
// .ico/.jpg/.png for other platforms.
func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0]))
C.setIcon(cstr, (C.int)(len(templateIconBytes)), true)
}
// SetIcon sets the icon of a menu item. Only works on macOS and Windows.
// iconBytes should be the content of .ico/.jpg/.png
func (item *MenuItem) SetIcon(iconBytes []byte) {
cstr := (*C.char)(unsafe.Pointer(&iconBytes[0]))
C.setMenuItemIcon(cstr, (C.int)(len(iconBytes)), C.int(item.id), false)
}
// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it
// falls back to the regular icon bytes and on Linux it does nothing.
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
// .ico/.jpg/.png for other platforms.
func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0]))
C.setMenuItemIcon(cstr, (C.int)(len(templateIconBytes)), C.int(item.id), true)
}

View File

@@ -1,32 +0,0 @@
//go:build never
// +build never
package systray
/*
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Cocoa -framework WebKit
#include "systray.h"
*/
import "C"
// SetTemplateIcon sets the systray icon as a template icon (on macOS), falling back
// to a regular icon on other platforms.
// templateIconBytes and iconBytes should be the content of .ico for windows and
// .ico/.jpg/.png for other platforms.
func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
SetIcon(regularIconBytes)
}
// SetIcon sets the icon of a menu item. Only works on macOS and Windows.
// iconBytes should be the content of .ico/.jpg/.png
func (item *MenuItem) SetIcon(iconBytes []byte) {
}
// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it
// falls back to the regular icon bytes and on Linux it does nothing.
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
// .ico/.jpg/.png for other platforms.
func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
}

View File

@@ -1,106 +0,0 @@
//go:build darwin
// +build darwin
package systray
/*
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Cocoa
#include "systray.h"
*/
import "C"
import (
"unsafe"
)
func registerSystray() {
C.registerSystray()
}
func nativeLoop() {
C.nativeLoop()
}
func quit() {
C.quit()
}
// SetIcon sets the systray icon.
// iconBytes should be the content of .ico for windows and .ico/.jpg/.png
// for other platforms.
func SetIcon(iconBytes []byte) {
cstr := (*C.char)(unsafe.Pointer(&iconBytes[0]))
C.setIcon(cstr, (C.int)(len(iconBytes)), false)
}
// SetTitle sets the systray title, only available on Mac and Linux.
func SetTitle(title string) {
C.setTitle(C.CString(title))
}
// SetTooltip sets the systray tooltip to display on mouse hover of the tray icon,
// only available on Mac and Windows.
func SetTooltip(tooltip string) {
C.setTooltip(C.CString(tooltip))
}
func addOrUpdateMenuItem(item *MenuItem) {
var disabled C.short
if item.disabled {
disabled = 1
}
var checked C.short
if item.checked {
checked = 1
}
var isCheckable C.short
if item.isCheckable {
isCheckable = 1
}
var parentID uint32 = 0
if item.parent != nil {
parentID = item.parent.id
}
C.add_or_update_menu_item(
C.int(item.id),
C.int(parentID),
C.CString(item.title),
C.CString(item.tooltip),
disabled,
checked,
isCheckable,
)
}
func addSeparator(id uint32) {
C.add_separator(C.int(id))
}
func hideMenuItem(item *MenuItem) {
C.hide_menu_item(
C.int(item.id),
)
}
func showMenuItem(item *MenuItem) {
C.show_menu_item(
C.int(item.id),
)
}
//export systray_ready
func systray_ready() {
systrayReady()
}
//export systray_on_exit
func systray_on_exit() {
systrayExit()
}
//export systray_menu_item_selected
func systray_menu_item_selected(cID C.int) {
systrayMenuItemSelected(uint32(cID))
}

View File

@@ -1,133 +0,0 @@
//go:build windows
// +build windows
package systray
import (
"io/ioutil"
"runtime"
"sync/atomic"
"testing"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const iconFilePath = "example/icon/iconwin.ico"
func TestBaseWindowsTray(t *testing.T) {
systrayReady = func() {}
systrayExit = func() {}
runtime.LockOSThread()
if err := wt.initInstance(); err != nil {
t.Fatalf("initInstance failed: %s", err)
}
if err := wt.createMenu(); err != nil {
t.Fatalf("createMenu failed: %s", err)
}
defer func() {
pDestroyWindow.Call(uintptr(wt.window))
wt.wcex.unregister()
}()
if err := wt.setIcon(iconFilePath); err != nil {
t.Errorf("SetIcon failed: %s", err)
}
if err := wt.setTooltip("Cyrillic tooltip тест:)"); err != nil {
t.Errorf("SetIcon failed: %s", err)
}
var id int32 = 0
err := wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple enabled", false, false)
if err != nil {
t.Errorf("mergeMenuItem failed: %s", err)
}
err = wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple disabled", true, false)
if err != nil {
t.Errorf("mergeMenuItem failed: %s", err)
}
err = wt.addSeparatorMenuItem(atomic.AddInt32(&id, 1))
if err != nil {
t.Errorf("addSeparatorMenuItem failed: %s", err)
}
err = wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple checked enabled", false, true)
if err != nil {
t.Errorf("mergeMenuItem failed: %s", err)
}
err = wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple checked disabled", true, true)
if err != nil {
t.Errorf("mergeMenuItem failed: %s", err)
}
err = wt.hideMenuItem(1)
if err != nil {
t.Errorf("hideMenuItem failed: %s", err)
}
err = wt.hideMenuItem(100)
if err == nil {
t.Error("hideMenuItem failed: must return error on invalid item id")
}
err = wt.addOrUpdateMenuItem(2, "Simple disabled update", true, false)
if err != nil {
t.Errorf("mergeMenuItem failed: %s", err)
}
time.AfterFunc(1*time.Second, quit)
m := struct {
WindowHandle windows.Handle
Message uint32
Wparam uintptr
Lparam uintptr
Time uint32
Pt point
}{}
for {
ret, _, err := pGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
res := int32(ret)
if res == -1 {
t.Errorf("win32 GetMessage failed: %v", err)
return
} else if res == 0 {
break
}
pTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
pDispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
}
}
func TestWindowsRun(t *testing.T) {
onReady := func() {
b, err := ioutil.ReadFile(iconFilePath)
if err != nil {
t.Fatalf("Can't load icon file: %v", err)
}
SetIcon(b)
SetTitle("Test title с кириллицей")
bSomeBtn := AddMenuItem("Йа кнопко", "")
bSomeBtn.Check()
AddSeparator()
bQuit := AddMenuItem("Quit", "Quit the whole app")
go func() {
<-bQuit.ClickedCh
t.Log("Quit reqested")
Quit()
}()
time.AfterFunc(1*time.Second, Quit)
}
onExit := func() {
t.Log("Exit success")
}
Run(onReady, onExit)
}

View File

@@ -5,15 +5,16 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"strings"
"time"
"github.com/nkanaev/yarr/src/content/scraper"
"github.com/nkanaev/yarr/src/parser"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
"golang.org/x/net/html/charset"
)
@@ -69,10 +70,10 @@ func DiscoverFeed(candidateUrl string) (*DiscoverResult, error) {
}
switch {
case len(sources) == 0:
return nil, errors.New("No feeds found at the given url")
return nil, errors.New("no feeds found at the given url")
case len(sources) == 1:
if sources[0].Url == candidateUrl {
return nil, errors.New("Recursion!")
return nil, errors.New("recursion")
}
return DiscoverFeed(sources[0].Url)
}
@@ -103,7 +104,7 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
if siteUrl != "" {
if res, err := client.get(siteUrl); err == nil {
defer res.Body.Close()
if body, err := ioutil.ReadAll(res.Body); err == nil {
if body, err := io.ReadAll(res.Body); err == nil {
urls = append(urls, scraper.FindIcons(string(body), siteUrl)...)
if c := favicon(siteUrl); c != "" {
urls = append(urls, c)
@@ -126,7 +127,7 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
continue
}
content, err := ioutil.ReadAll(res.Body)
content, err := io.ReadAll(res.Body)
if err != nil {
continue
}
@@ -139,34 +140,33 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
return &emptyIcon, nil
}
func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
result := make([]storage.Item, len(items))
func ConvertItems(items []parser.Item, feed model.Feed) []model.Item {
result := make([]model.Item, len(items))
for i, item := range items {
item := item
mediaLinks := make(storage.MediaLinks, 0)
mediaLinks := make(model.MediaLinks, 0)
for _, link := range item.MediaLinks {
mediaLinks = append(mediaLinks, storage.MediaLink(link))
mediaLinks = append(mediaLinks, model.MediaLink(link))
}
result[i] = storage.Item{
result[i] = model.Item{
GUID: item.GUID,
FeedId: feed.Id,
Title: item.Title,
Link: item.URL,
Content: item.Content,
Date: item.Date,
Status: storage.UNREAD,
Status: model.UNREAD,
MediaLinks: mediaLinks,
}
}
return result
}
func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
func listItems(f model.Feed, db storage.Storage) ([]model.Item, error) {
lmod := ""
etag := ""
if state := db.GetHTTPState(f.Id); state != nil {
lmod = state.LastModified
etag = state.Etag
if state, _ := db.GetFeedState(f.Id); state != nil {
lmod = state.HTTPLastModified
etag = state.HTTPEtag
}
res, err := client.getConditional(f.FeedLink, lmod, etag)
@@ -192,8 +192,13 @@ func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
lmod = res.Header.Get("Last-Modified")
etag = res.Header.Get("Etag")
now := time.Now().UTC()
if lmod != "" || etag != "" {
db.SetHTTPState(f.Id, lmod, etag)
db.UpdateFeedState(f.Id, model.UpdateFeedStateParams{
HTTPLastModified: &lmod,
HTTPEtag: &etag,
LastRefreshed: &now,
})
}
return ConvertItems(feed.Items, f), nil
}

View File

@@ -7,19 +7,20 @@ import (
"time"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/storage/model"
)
const NUM_WORKERS = 4
type Worker struct {
db *storage.Storage
db storage.Storage
pending *int32
refresh *time.Ticker
reflock sync.Mutex
stopper chan bool
}
func NewWorker(db *storage.Storage) *Worker {
func NewWorker(db storage.Storage) *Worker {
pending := int32(0)
return &Worker{db: db, pending: &pending}
}
@@ -39,21 +40,13 @@ func (w *Worker) StartFeedCleaner() {
}()
}
func (w *Worker) FindFavicons() {
go func() {
for _, feed := range w.db.ListFeedsMissingIcons() {
w.FindFeedFavicon(feed)
}
}()
}
func (w *Worker) FindFeedFavicon(feed storage.Feed) {
func (w *Worker) FindFeedFavicon(feed model.Feed) {
icon, err := findFavicon(feed.Link, feed.FeedLink)
if err != nil {
log.Printf("Failed to find favicon for %s (%s): %s", feed.FeedLink, feed.Link, err)
}
if icon != nil {
w.db.UpdateFeedIcon(feed.Id, icon)
w.db.UpdateFeed(feed.Id, model.UpdateFeedParams{Icon: model.SetNullable(icon)})
}
}
@@ -107,27 +100,25 @@ func (w *Worker) RefreshFeeds() {
go w.refresher(feeds)
}
func (w *Worker) refresher(feeds []storage.Feed) {
w.db.ResetFeedErrors()
func (w *Worker) refresher(feeds []model.Feed) {
// w.db.ResetFeedErrors()
srcqueue := make(chan storage.Feed, len(feeds))
dstqueue := make(chan []storage.Item)
srcqueue := make(chan model.Feed, len(feeds))
dstqueue := make(chan []model.Item)
for i := 0; i < NUM_WORKERS; i++ {
for range NUM_WORKERS {
go w.worker(srcqueue, dstqueue)
}
for _, feed := range feeds {
srcqueue <- feed
}
for i := 0; i < len(feeds); i++ {
for range feeds {
items := <-dstqueue
if len(items) > 0 {
w.db.CreateItems(items)
w.db.SetFeedSize(items[0].FeedId, len(items))
}
atomic.AddInt32(w.pending, -1)
w.db.SyncSearch()
}
close(srcqueue)
close(dstqueue)
@@ -135,11 +126,18 @@ func (w *Worker) refresher(feeds []storage.Feed) {
log.Printf("Finished refreshing %d feeds", len(feeds))
}
func (w *Worker) worker(srcqueue <-chan storage.Feed, dstqueue chan<- []storage.Item) {
func (w *Worker) worker(srcqueue <-chan model.Feed, dstqueue chan<- []model.Item) {
for feed := range srcqueue {
empty := ""
w.db.UpdateFeedState(feed.Id, model.UpdateFeedStateParams{LastError: &empty})
items, err := listItems(feed, w.db)
if err != nil {
w.db.SetFeedError(feed.Id, err)
errMsg := err.Error()
w.db.UpdateFeedState(feed.Id, model.UpdateFeedStateParams{LastError: &errMsg})
}
if len(items) > 0 && !feed.HasIcon {
w.FindFeedFavicon(feed)
}
dstqueue <- items
}

View File

@@ -10,3 +10,4 @@ dll/systray_unsigned.dll
out.txt
.vs
on_exit*.txt
.vscode

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