mirror of
https://github.com/nkanaev/yarr.git
synced 2026-06-09 18:03:19 +00:00
Compare commits
23 Commits
7a5f8a5e41
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d30124bf3c | ||
|
|
138b5ad991 | ||
|
|
2f263e9803 | ||
|
|
76529c895e | ||
|
|
847ec3861a | ||
|
|
85f3956b24 | ||
|
|
7553824520 | ||
|
|
54e197ad85 | ||
|
|
f50894ddb0 | ||
|
|
59af8aa62d | ||
|
|
31274d17a5 | ||
|
|
450f64605e | ||
|
|
391e2dd2c8 | ||
|
|
8fc01db275 | ||
|
|
76c2b9a475 | ||
|
|
14d5a6b52b | ||
|
|
6069330e92 | ||
|
|
552ebb7ad5 | ||
|
|
74e6ee8e8e | ||
|
|
167aef9ba1 | ||
|
|
ed726f26f4 | ||
|
|
760f611007 | ||
|
|
49c704037b |
254
doc/fever-api.md
Normal file
254
doc/fever-api.md
Normal 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 API’s 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.
|
||||||
|
|
||||||
|
I’ve 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 PHP’s 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 won’t 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 client’s 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 client’s 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 client’s last `items` API request
|
||||||
1755
doc/fever-api.mhtml
Normal file
1755
doc/fever-api.mhtml
Normal file
File diff suppressed because it is too large
Load Diff
2
makefile
2
makefile
@@ -1,7 +1,7 @@
|
|||||||
VERSION=2.6
|
VERSION=2.6
|
||||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
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_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||||
|
|
||||||
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func Template(path string) *template.Template {
|
|||||||
return tmpl
|
return tmpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func Render(path string, writer io.Writer, data interface{}) {
|
func Render(path string, writer io.Writer, data any) {
|
||||||
tmpl := Template(path)
|
tmpl := Template(path)
|
||||||
tmpl.Execute(writer, data)
|
tmpl.Execute(writer, data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,43 +27,43 @@
|
|||||||
<button class="toolbar-item ml-1"
|
<button class="toolbar-item ml-1"
|
||||||
:class="{active: filterSelected == 'unread'}"
|
:class="{active: filterSelected == 'unread'}"
|
||||||
:aria-pressed="filterSelected == 'unread'"
|
:aria-pressed="filterSelected == 'unread'"
|
||||||
title="Unread"
|
:title="$t('unread')"
|
||||||
@click="filterSelected = 'unread'">
|
@click="filterSelected = 'unread'">
|
||||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item mx-1"
|
<button class="toolbar-item mx-1"
|
||||||
:class="{active: filterSelected == 'starred'}"
|
:class="{active: filterSelected == 'starred'}"
|
||||||
:aria-pressed="filterSelected == 'starred'"
|
:aria-pressed="filterSelected == 'starred'"
|
||||||
title="Starred"
|
:title="$t('starred')"
|
||||||
@click="filterSelected = 'starred'">
|
@click="filterSelected = 'starred'">
|
||||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item mr-1"
|
<button class="toolbar-item mr-1"
|
||||||
:class="{active: filterSelected == ''}"
|
:class="{active: filterSelected == ''}"
|
||||||
:aria-pressed="filterSelected == ''"
|
:aria-pressed="filterSelected == ''"
|
||||||
title="All"
|
:title="$t('all')"
|
||||||
@click="filterSelected = ''">
|
@click="filterSelected = ''">
|
||||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
<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>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button class="dropdown-item" @click="showSettings('create')">
|
<button class="dropdown-item" @click="showSettings('create')">
|
||||||
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||||
New Feed
|
{{ $t('new_feed') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||||
Refresh Feeds
|
{{ $t('refresh_feeds') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<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">
|
<div class="row text-center m-0">
|
||||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||||
:class="'theme-'+t"
|
:class="'theme-'+t"
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<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">
|
<div class="row text-center m-0">
|
||||||
<button class="dropdown-item col-4 px-0"
|
<button class="dropdown-item col-4 px-0"
|
||||||
@click.stop="changeRefreshRate(-1)"
|
@click.stop="changeRefreshRate(-1)"
|
||||||
@@ -97,13 +97,13 @@
|
|||||||
|
|
||||||
<div class="dropdown-divider"></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">
|
<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=true">{{ $t('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=false">{{ $t('old') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-divider"></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">
|
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||||
<input type="file"
|
<input type="file"
|
||||||
id="opml-import"
|
id="opml-import"
|
||||||
@@ -112,22 +112,31 @@
|
|||||||
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||||
Import
|
{{ $t('import') }}
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
<a class="dropdown-item" href="./opml/export">
|
<a class="dropdown-item" href="./opml/export">
|
||||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||||
Export
|
{{ $t('export') }}
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||||
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||||
Shortcuts
|
{{ $t('shortcuts') }}
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header>
|
||||||
|
<button
|
||||||
|
v-for="lang in languages"
|
||||||
|
class="dropdown-item"
|
||||||
|
:class="{active: language==lang.code}"
|
||||||
|
@click.stop="changeLanguage(lang.code)">
|
||||||
|
{{ lang.name }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider" v-if="authenticated"></div>
|
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||||
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||||
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
||||||
Log out
|
{{ $t('log_out') }}
|
||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,9 +145,9 @@
|
|||||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||||
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
<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=='unread'">{{ $t('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=='starred'">{{ $t('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==''">{{ $t('all_feeds') }}</span>
|
||||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -179,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
<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="icon loading mx-2"></span>
|
||||||
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
|
<span class="text-truncate cursor-default noselect">{{ $t('refreshing') }} ({{ loading.feeds }} {{ $t('left') }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- item list -->
|
<!-- item list -->
|
||||||
@@ -188,7 +197,7 @@
|
|||||||
<div class="px-2 toolbar d-flex align-items-center">
|
<div class="px-2 toolbar d-flex align-items-center">
|
||||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||||
@click="feedSelected = null"
|
@click="feedSelected = null"
|
||||||
title="Show Feeds">
|
:title="$t('show_feeds')">
|
||||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="input-icon flex-grow-1">
|
<div class="input-icon flex-grow-1">
|
||||||
@@ -199,7 +208,7 @@
|
|||||||
<button class="toolbar-item ml-2"
|
<button class="toolbar-item ml-2"
|
||||||
@click="markItemsRead()"
|
@click="markItemsRead()"
|
||||||
v-if="filterSelected == 'unread'"
|
v-if="filterSelected == 'unread'"
|
||||||
title="Mark All Read">
|
:title="$t('mark_all_read')">
|
||||||
<span class="icon">{% inline "check.svg" %}</span>
|
<span class="icon">{% inline "check.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -210,7 +219,7 @@
|
|||||||
<dropdown class="settings-dropdown"
|
<dropdown class="settings-dropdown"
|
||||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
drop="right"
|
drop="right"
|
||||||
title="Feed Settings"
|
:title="$t('feed_settings')"
|
||||||
v-if="current.type == 'feed'">
|
v-if="current.type == 'feed'">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
@@ -218,23 +227,23 @@
|
|||||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||||
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
||||||
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||||
Website
|
{{ $t('website') }}
|
||||||
</a>
|
</a>
|
||||||
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
||||||
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
||||||
Feed Link
|
{{ $t('feed_link') }}
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
||||||
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Rename
|
{{ $t('rename') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Change Link
|
{{ $t('change_link') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<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"
|
<button class="dropdown-item"
|
||||||
v-if="folder.id != current.feed.folder_id"
|
v-if="folder.id != current.feed.folder_id"
|
||||||
v-for="folder in folders"
|
v-for="folder in folders"
|
||||||
@@ -248,17 +257,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||||
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
||||||
new folder
|
{{ $t('new_folder') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
||||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||||
Delete
|
{{ $t('delete') }}
|
||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
<dropdown class="settings-dropdown"
|
<dropdown class="settings-dropdown"
|
||||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
title="Folder Settings"
|
:title="$t('folder_settings')"
|
||||||
drop="right"
|
drop="right"
|
||||||
v-if="current.type == 'folder'">
|
v-if="current.type == 'folder'">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
@@ -267,12 +276,12 @@
|
|||||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Rename
|
{{ $t('rename') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
||||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||||
Delete
|
{{ $t('delete') }}
|
||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +300,7 @@
|
|||||||
</small>
|
</small>
|
||||||
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ item.title || 'untitled' }}</div>
|
<div>{{ item.title || $t('untitled') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||||
@@ -305,24 +314,24 @@
|
|||||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item"
|
||||||
@click="toggleItemStarred(itemSelectedDetails)"
|
@click="toggleItemStarred(itemSelectedDetails)"
|
||||||
title="Mark Starred">
|
:title="$t('mark_starred')">
|
||||||
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
||||||
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item"
|
||||||
title="Mark Unread"
|
:title="$t('mark_unread')"
|
||||||
@click="toggleItemRead(itemSelectedDetails)">
|
@click="toggleItemRead(itemSelectedDetails)">
|
||||||
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||||
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<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>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</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'">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'">monospace</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">
|
<div class="d-flex text-center">
|
||||||
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||||
@@ -332,20 +341,20 @@
|
|||||||
<button class="toolbar-item"
|
<button class="toolbar-item"
|
||||||
:class="{active: itemSelectedReadability}"
|
:class="{active: itemSelectedReadability}"
|
||||||
@click="toggleReadability()"
|
@click="toggleReadability()"
|
||||||
title="Read Here">
|
:title="$t('read_here')">
|
||||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<a class="toolbar-item" :href="itemSelectedDetails.link" 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>
|
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="!items.length || 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>
|
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="!items.length || 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>
|
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||||
</button>
|
</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>
|
<span class="icon">{% inline "x.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +364,7 @@
|
|||||||
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||||
:style="{'font-size': theme.size + 'rem'}">
|
:style="{'font-size': theme.size + 'rem'}">
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<div>
|
<div>
|
||||||
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
||||||
@@ -384,13 +393,13 @@
|
|||||||
<span class="icon">{% inline "x.svg" %}</span>
|
<span class="icon">{% inline "x.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="settings=='create'">
|
<div v-if="settings=='create'">
|
||||||
<p class="cursor-default"><b>New Feed</b></p>
|
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
|
||||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||||
<label for="feed-url">URL</label>
|
<label for="feed-url">{{ $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>
|
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||||
<label for="feed-folder" class="mt-3 d-block">
|
<label for="feed-folder" class="mt-3 d-block">
|
||||||
Folder
|
{{ $t('folder') }}
|
||||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
|
||||||
</label>
|
</label>
|
||||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||||
<option value="">---</option>
|
<option value="">---</option>
|
||||||
@@ -398,8 +407,8 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="mt-4" v-if="feedNewChoice.length">
|
<div class="mt-4" v-if="feedNewChoice.length">
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
Multiple feeds found. Choose one below:
|
{{ $t('multiple_feeds_found') }}
|
||||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
|
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
|
||||||
</p>
|
</p>
|
||||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||||
@@ -409,29 +418,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="settings=='shortcuts'">
|
<div v-else-if="settings=='shortcuts'">
|
||||||
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
|
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
|
||||||
|
|
||||||
<table class="table table-borderless table-sm table-compact m-0">
|
<table class="table table-borderless table-sm table-compact m-0">
|
||||||
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
||||||
<td>show unread / starred / all feeds</td></tr>
|
<td>{{ $t('kb_show_filters') }}</td></tr>
|
||||||
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
|
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
|
||||||
|
|
||||||
<tr><td colspan=2> </td></tr>
|
<tr><td colspan=2> </td></tr>
|
||||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
|
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
|
||||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
|
||||||
<tr><td><kbd>q</kbd></td> <td>close article</td></tr>
|
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
|
||||||
|
|
||||||
<tr><td colspan=2> </td></tr>
|
<tr><td colspan=2> </td></tr>
|
||||||
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
|
||||||
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
|
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
|
||||||
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
|
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
|
||||||
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
|
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
|
||||||
<tr><td><kbd>i</kbd></td> <td>read here</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>scroll content forward / backward</td>
|
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,6 +449,7 @@
|
|||||||
<!-- external -->
|
<!-- external -->
|
||||||
<script src="./static/javascripts/vue.min.js"></script>
|
<script src="./static/javascripts/vue.min.js"></script>
|
||||||
<!-- internal -->
|
<!-- internal -->
|
||||||
|
<script src="./static/javascripts/i18n.js"></script>
|
||||||
<script src="./static/javascripts/api.js"></script>
|
<script src="./static/javascripts/api.js"></script>
|
||||||
<script src="./static/javascripts/app.js"></script>
|
<script src="./static/javascripts/app.js"></script>
|
||||||
<script src="./static/javascripts/key.js"></script>
|
<script src="./static/javascripts/key.js"></script>
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ Vue.component('relative-time', {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Vue.use(i18n)
|
||||||
|
|
||||||
var vm = new Vue({
|
var vm = new Vue({
|
||||||
created: function() {
|
created: function() {
|
||||||
this.refreshStats()
|
this.refreshStats()
|
||||||
@@ -212,6 +214,7 @@ var vm = new Vue({
|
|||||||
vm.feed_errors = errors
|
vm.feed_errors = errors
|
||||||
})
|
})
|
||||||
this.updateMetaTheme(app.settings.theme_name)
|
this.updateMetaTheme(app.settings.theme_name)
|
||||||
|
this.$setLang(app.settings.language)
|
||||||
},
|
},
|
||||||
data: function() {
|
data: function() {
|
||||||
var s = app.settings
|
var s = app.settings
|
||||||
@@ -269,6 +272,13 @@ var vm = new Vue({
|
|||||||
{ title: "12h", value: 720 },
|
{ title: "12h", value: 720 },
|
||||||
{ title: "24h", value: 1440 },
|
{ title: "24h", value: 1440 },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'language': s.language,
|
||||||
|
'languages': [
|
||||||
|
{code: 'en', name: 'English' },
|
||||||
|
{code: 'zh', name: '简体中文'},
|
||||||
|
{code: 'ru', name: 'Русский'},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -834,6 +844,11 @@ var vm = new Vue({
|
|||||||
&& !this.filteredFeedStats[feed.id]
|
&& !this.filteredFeedStats[feed.id]
|
||||||
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
||||||
},
|
},
|
||||||
|
changeLanguage(lang) {
|
||||||
|
this.$setLang(lang)
|
||||||
|
this.language = lang
|
||||||
|
api.settings.update({language: lang})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
372
src/assets/javascripts/i18n.js
Normal file
372
src/assets/javascripts/i18n.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
(function (exports) {
|
||||||
|
const translations = {
|
||||||
|
"unread": {
|
||||||
|
"en": "Unread",
|
||||||
|
"zh": "未读",
|
||||||
|
"ru": "Непрочитанные"
|
||||||
|
},
|
||||||
|
"starred": {
|
||||||
|
"en": "Starred",
|
||||||
|
"zh": "星标",
|
||||||
|
"ru": "Избранные"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"en": "All",
|
||||||
|
"zh": "全部",
|
||||||
|
"ru": "Все"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"en": "Settings",
|
||||||
|
"zh": "设置",
|
||||||
|
"ru": "Настройки"
|
||||||
|
},
|
||||||
|
"new_feed": {
|
||||||
|
"en": "New Feed",
|
||||||
|
"zh": "新建订阅",
|
||||||
|
"ru": "Новая лента"
|
||||||
|
},
|
||||||
|
"refresh_feeds": {
|
||||||
|
"en": "Refresh Feeds",
|
||||||
|
"zh": "刷新订阅",
|
||||||
|
"ru": "Обновить ленты"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"en": "Theme",
|
||||||
|
"zh": "主题",
|
||||||
|
"ru": "Тема"
|
||||||
|
},
|
||||||
|
"auto_refresh": {
|
||||||
|
"en": "Auto Refresh",
|
||||||
|
"zh": "自动刷新",
|
||||||
|
"ru": "Автообновление"
|
||||||
|
},
|
||||||
|
"show_first": {
|
||||||
|
"en": "Show first",
|
||||||
|
"zh": "优先显示",
|
||||||
|
"ru": "Сначала"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"en": "New",
|
||||||
|
"zh": "最新",
|
||||||
|
"ru": "Новые"
|
||||||
|
},
|
||||||
|
"old": {
|
||||||
|
"en": "Old",
|
||||||
|
"zh": "最旧",
|
||||||
|
"ru": "Старые"
|
||||||
|
},
|
||||||
|
"subscriptions": {
|
||||||
|
"en": "Subscriptions",
|
||||||
|
"zh": "订阅管理",
|
||||||
|
"ru": "Подписки"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"en": "Import",
|
||||||
|
"zh": "导入",
|
||||||
|
"ru": "Импорт"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"en": "Export",
|
||||||
|
"zh": "导出",
|
||||||
|
"ru": "Экспорт"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"en": "Shortcuts",
|
||||||
|
"zh": "快捷键",
|
||||||
|
"ru": "Горячие клавиши"
|
||||||
|
},
|
||||||
|
"log_out": {
|
||||||
|
"en": "Log out",
|
||||||
|
"zh": "登出",
|
||||||
|
"ru": "Выйти"
|
||||||
|
},
|
||||||
|
"all_unread": {
|
||||||
|
"en": "All Unread",
|
||||||
|
"zh": "全部未读",
|
||||||
|
"ru": "Все непрочитанные"
|
||||||
|
},
|
||||||
|
"all_starred": {
|
||||||
|
"en": "All Starred",
|
||||||
|
"zh": "全部星标",
|
||||||
|
"ru": "Все избранные"
|
||||||
|
},
|
||||||
|
"all_feeds": {
|
||||||
|
"en": "All Feeds",
|
||||||
|
"zh": "全部订阅",
|
||||||
|
"ru": "Все ленты"
|
||||||
|
},
|
||||||
|
"refreshing": {
|
||||||
|
"en": "Refreshing",
|
||||||
|
"zh": "正在刷新",
|
||||||
|
"ru": "Обновление"
|
||||||
|
},
|
||||||
|
"left": {
|
||||||
|
"en": "left",
|
||||||
|
"zh": "剩余",
|
||||||
|
"ru": "осталось"
|
||||||
|
},
|
||||||
|
"show_feeds": {
|
||||||
|
"en": "Show Feeds",
|
||||||
|
"zh": "显示订阅",
|
||||||
|
"ru": "Показать ленты"
|
||||||
|
},
|
||||||
|
"mark_all_read": {
|
||||||
|
"en": "Mark All Read",
|
||||||
|
"zh": "全部标记为已读",
|
||||||
|
"ru": "Отметить все как прочитанные"
|
||||||
|
},
|
||||||
|
"feed_settings": {
|
||||||
|
"en": "Feed Settings",
|
||||||
|
"zh": "订阅设置",
|
||||||
|
"ru": "Настройки ленты"
|
||||||
|
},
|
||||||
|
"folder_settings": {
|
||||||
|
"en": "Folder Settings",
|
||||||
|
"zh": "文件夹设置",
|
||||||
|
"ru": "Настройки папки"
|
||||||
|
},
|
||||||
|
"website": {
|
||||||
|
"en": "Website",
|
||||||
|
"zh": "网站",
|
||||||
|
"ru": "Сайт"
|
||||||
|
},
|
||||||
|
"feed_link": {
|
||||||
|
"en": "Feed Link",
|
||||||
|
"zh": "订阅链接",
|
||||||
|
"ru": "Ссылка на ленту"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"en": "Rename",
|
||||||
|
"zh": "重命名",
|
||||||
|
"ru": "Переименовать"
|
||||||
|
},
|
||||||
|
"change_link": {
|
||||||
|
"en": "Change Link",
|
||||||
|
"zh": "修改链接",
|
||||||
|
"ru": "Изменить ссылку"
|
||||||
|
},
|
||||||
|
"move_to": {
|
||||||
|
"en": "Move to...",
|
||||||
|
"zh": "移动到...",
|
||||||
|
"ru": "Переместить в..."
|
||||||
|
},
|
||||||
|
"new_folder": {
|
||||||
|
"en": "new folder",
|
||||||
|
"zh": "新建文件夹",
|
||||||
|
"ru": "новая папка"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"en": "Delete",
|
||||||
|
"zh": "删除",
|
||||||
|
"ru": "Удалить"
|
||||||
|
},
|
||||||
|
"mark_starred": {
|
||||||
|
"en": "Mark Starred",
|
||||||
|
"zh": "标记星标",
|
||||||
|
"ru": "Пометить избранным"
|
||||||
|
},
|
||||||
|
"mark_unread": {
|
||||||
|
"en": "Mark Unread",
|
||||||
|
"zh": "标记未读",
|
||||||
|
"ru": "Пометить непрочитанным"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"en": "Appearance",
|
||||||
|
"zh": "外观",
|
||||||
|
"ru": "Внешний вид"
|
||||||
|
},
|
||||||
|
"read_here": {
|
||||||
|
"en": "Read Here",
|
||||||
|
"zh": "在此阅读",
|
||||||
|
"ru": "Читать здесь"
|
||||||
|
},
|
||||||
|
"open_link": {
|
||||||
|
"en": "Open Link",
|
||||||
|
"zh": "打开链接",
|
||||||
|
"ru": "Открыть ссылку"
|
||||||
|
},
|
||||||
|
"previous_article": {
|
||||||
|
"en": "Previous Article",
|
||||||
|
"zh": "上一篇",
|
||||||
|
"ru": "Предыдущая статья"
|
||||||
|
},
|
||||||
|
"next_article": {
|
||||||
|
"en": "Next Article",
|
||||||
|
"zh": "下一篇",
|
||||||
|
"ru": "Следующая статья"
|
||||||
|
},
|
||||||
|
"close_article": {
|
||||||
|
"en": "Close Article",
|
||||||
|
"zh": "关闭文章",
|
||||||
|
"ru": "Закрыть статью"
|
||||||
|
},
|
||||||
|
"untitled": {
|
||||||
|
"en": "untitled",
|
||||||
|
"zh": "无标题",
|
||||||
|
"ru": "без названия"
|
||||||
|
},
|
||||||
|
"sans_serif": {
|
||||||
|
"en": "sans-serif",
|
||||||
|
"zh": "无衬线",
|
||||||
|
"ru": "sans-serif"
|
||||||
|
},
|
||||||
|
"serif": {
|
||||||
|
"en": "serif",
|
||||||
|
"zh": "衬线",
|
||||||
|
"ru": "serif"
|
||||||
|
},
|
||||||
|
"monospace": {
|
||||||
|
"en": "monospace",
|
||||||
|
"zh": "等宽",
|
||||||
|
"ru": "monospace"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"en": "URL",
|
||||||
|
"zh": "网址",
|
||||||
|
"ru": "URL"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"en": "Folder",
|
||||||
|
"zh": "文件夹",
|
||||||
|
"ru": "Папка"
|
||||||
|
},
|
||||||
|
"add": {
|
||||||
|
"en": "Add",
|
||||||
|
"zh": "添加",
|
||||||
|
"ru": "Добавить"
|
||||||
|
},
|
||||||
|
"keyboard_shortcuts": {
|
||||||
|
"en": "Keyboard Shortcuts",
|
||||||
|
"zh": "键盘快捷键",
|
||||||
|
"ru": "Горячие клавиши"
|
||||||
|
},
|
||||||
|
"multiple_feeds_found": {
|
||||||
|
"en": "Multiple feeds found. Choose one below:",
|
||||||
|
"zh": "找到多个订阅源,请选择一个:",
|
||||||
|
"ru": "Найдено несколько лент. Выберите одну:"
|
||||||
|
},
|
||||||
|
"cancel": {
|
||||||
|
"en": "cancel",
|
||||||
|
"zh": "取消",
|
||||||
|
"ru": "отмена"
|
||||||
|
},
|
||||||
|
"kb_show_filters": {
|
||||||
|
"en": "show unread / starred / all feeds",
|
||||||
|
"zh": "显示未读/星标/全部订阅",
|
||||||
|
"ru": "показать непрочитанные / избранные / все ленты"
|
||||||
|
},
|
||||||
|
"kb_focus_search": {
|
||||||
|
"en": "focus the search bar",
|
||||||
|
"zh": "聚焦搜索栏",
|
||||||
|
"ru": "фокус на строку поиска"
|
||||||
|
},
|
||||||
|
"kb_next_prev_article": {
|
||||||
|
"en": "next / prev article",
|
||||||
|
"zh": "下一篇/上一篇文章",
|
||||||
|
"ru": "следующая / предыдущая статья"
|
||||||
|
},
|
||||||
|
"kb_next_prev_feed": {
|
||||||
|
"en": "next / prev feed",
|
||||||
|
"zh": "下一个/上一个订阅",
|
||||||
|
"ru": "следующая / предыдущая лента"
|
||||||
|
},
|
||||||
|
"kb_close_article": {
|
||||||
|
"en": "close article",
|
||||||
|
"zh": "关闭文章",
|
||||||
|
"ru": "закрыть статью"
|
||||||
|
},
|
||||||
|
"kb_mark_all_read": {
|
||||||
|
"en": "mark all read",
|
||||||
|
"zh": "全部标记为已读",
|
||||||
|
"ru": "отметить все как прочитанные"
|
||||||
|
},
|
||||||
|
"kb_mark_read": {
|
||||||
|
"en": "mark read / unread",
|
||||||
|
"zh": "标记已读/未读",
|
||||||
|
"ru": "отметить как прочитанное / непрочитанное"
|
||||||
|
},
|
||||||
|
"kb_mark_starred": {
|
||||||
|
"en": "mark starred / unstarred",
|
||||||
|
"zh": "标记星标/取消星标",
|
||||||
|
"ru": "пометить избранным / убрать из избранного"
|
||||||
|
},
|
||||||
|
"kb_open_link": {
|
||||||
|
"en": "open link",
|
||||||
|
"zh": "打开链接",
|
||||||
|
"ru": "открыть ссылку"
|
||||||
|
},
|
||||||
|
"kb_read_here": {
|
||||||
|
"en": "read here",
|
||||||
|
"zh": "在此阅读",
|
||||||
|
"ru": "читать здесь"
|
||||||
|
},
|
||||||
|
"kb_scroll_content": {
|
||||||
|
"en": "scroll content forward / backward",
|
||||||
|
"zh": "向前/向后滚动内容",
|
||||||
|
"ru": "прокрутка вперед / назад"
|
||||||
|
},
|
||||||
|
"prompt_folder_name": {
|
||||||
|
"en": "Enter folder name:",
|
||||||
|
"zh": "请输入文件夹名称:",
|
||||||
|
"ru": "Введите имя папки:"
|
||||||
|
},
|
||||||
|
"prompt_new_title": {
|
||||||
|
"en": "Enter new title",
|
||||||
|
"zh": "请输入新标题",
|
||||||
|
"ru": "Введите новый заголовок"
|
||||||
|
},
|
||||||
|
"prompt_feed_link": {
|
||||||
|
"en": "Enter feed link",
|
||||||
|
"zh": "请输入订阅链接",
|
||||||
|
"ru": "Введите ссылку на ленту"
|
||||||
|
},
|
||||||
|
"confirm_delete_folder": {
|
||||||
|
"en": "Are you sure you want to delete",
|
||||||
|
"zh": "确定要删除",
|
||||||
|
"ru": "Вы уверены, что хотите удалить"
|
||||||
|
},
|
||||||
|
"confirm_delete_feed": {
|
||||||
|
"en": "Are you sure you want to delete",
|
||||||
|
"zh": "确定要删除",
|
||||||
|
"ru": "Вы уверены, что хотите удалить"
|
||||||
|
},
|
||||||
|
"alert_no_feeds": {
|
||||||
|
"en": "No feeds found at the given url.",
|
||||||
|
"zh": "在指定的网址未找到订阅源。",
|
||||||
|
"ru": "Лент по данному адресу не найдено."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"en": "Login",
|
||||||
|
"zh": "登录",
|
||||||
|
"ru": "Вход"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"en": "Username",
|
||||||
|
"zh": "用户名",
|
||||||
|
"ru": "Имя пользователя"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"en": "Password",
|
||||||
|
"zh": "密码",
|
||||||
|
"ru": "Пароль"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
class i18n {
|
||||||
|
constructor() {
|
||||||
|
this.lang = 'en'
|
||||||
|
}
|
||||||
|
setLang(lang) {
|
||||||
|
this.lang = lang
|
||||||
|
}
|
||||||
|
$t(code) {
|
||||||
|
return translations[code][this.lang]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.i18n = {
|
||||||
|
install(Vue, opts) {
|
||||||
|
const x = new i18n();
|
||||||
|
Vue.prototype.$t = x.$t
|
||||||
|
Vue.prototype.$setLang = x.setLang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(window)
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -225,10 +226,8 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
|
|||||||
for element, attrs := range elements {
|
for element, attrs := range elements {
|
||||||
if tagName == element {
|
if tagName == element {
|
||||||
for _, attribute := range attributes {
|
for _, attribute := range attributes {
|
||||||
for _, attr := range attrs {
|
if slices.Contains(attrs, attribute) {
|
||||||
if attr == attribute {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,13 +284,7 @@ func isValidIframeSource(baseURL, src string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, safeDomain := range whitelist {
|
return slices.Contains(whitelist, domain)
|
||||||
if safeDomain == domain {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTagAllowList() map[string][]string {
|
func getTagAllowList() map[string][]string {
|
||||||
@@ -355,13 +348,7 @@ func getTagAllowList() map[string][]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func inList(needle string, haystack []string) bool {
|
func inList(needle string, haystack []string) bool {
|
||||||
for _, element := range haystack {
|
return slices.Contains(haystack, needle)
|
||||||
if element == needle {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isBlockedTag(tagName string) bool {
|
func isBlockedTag(tagName string) bool {
|
||||||
@@ -371,13 +358,7 @@ func isBlockedTag(tagName string) bool {
|
|||||||
"style",
|
"style",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, element := range blacklist {
|
return slices.Contains(blacklist, tagName)
|
||||||
if element == tagName {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package scraper
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
"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 {
|
isFeedLink := func(n *html.Node) bool {
|
||||||
if n.Type == html.ElementNode && n.Data == "link" {
|
if n.Type == html.ElementNode && n.Data == "link" {
|
||||||
t := htmlutil.Attr(n, "type")
|
t := htmlutil.Attr(n, "type")
|
||||||
for _, tt := range linkTypes {
|
if slices.Contains(linkTypes, t) {
|
||||||
if tt == t {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ func TestRSSTitleHTMLTags(t *testing.T) {
|
|||||||
`))
|
`))
|
||||||
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
||||||
want := []string{"title in p", "very strong 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] {
|
if want[i] != have[i] {
|
||||||
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ func TestRSSIsPermalink(t *testing.T) {
|
|||||||
URL: "http://example.com/posts/1",
|
URL: "http://example.com/posts/1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for i := 0; i < len(want); i++ {
|
for i := range want {
|
||||||
if !reflect.DeepEqual(want, have) {
|
if !reflect.DeepEqual(want, have) {
|
||||||
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestSafeXMLReaderPartial1(t *testing.T) {
|
|||||||
f = NewSafeXMLReader(f)
|
f = NewSafeXMLReader(f)
|
||||||
|
|
||||||
buf := make([]byte, 1)
|
buf := make([]byte, 1)
|
||||||
for i := 0; i < len(want); i++ {
|
for i := range want {
|
||||||
n, err := f.Read(buf)
|
n, err := f.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ func (m *Middleware) Handler(c *router.Context) {
|
|||||||
c.Redirect(rootUrl)
|
c.Redirect(rootUrl)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||||
"username": username,
|
"username": username,
|
||||||
"error": "Invalid username/password",
|
"error": "Invalid username/password",
|
||||||
"settings": m.DB.GetSettings(),
|
"settings": m.DB.GetSettings().Map(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||||
"settings": m.DB.GetSettings(),
|
"settings": m.DB.GetSettings().Map(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,20 +53,17 @@ type FeverFavicon struct {
|
|||||||
Data string `json:"data"`
|
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["api_version"] = 3
|
||||||
data["auth"] = 1
|
data["auth"] = 1
|
||||||
|
// TODO: remove duplicates
|
||||||
data["last_refreshed_on_time"] = lastRefreshed
|
data["last_refreshed_on_time"] = lastRefreshed
|
||||||
c.JSON(http.StatusOK, data)
|
c.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLastRefreshedOnTime(httpStates map[int64]storage.HTTPState) int64 {
|
func getLastRefreshedOnTime(feedStates []storage.FeedState) int64 {
|
||||||
if len(httpStates) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastRefreshed int64
|
var lastRefreshed int64
|
||||||
for _, state := range httpStates {
|
for _, state := range feedStates {
|
||||||
if state.LastRefreshed.Unix() > lastRefreshed {
|
if state.LastRefreshed.Unix() > lastRefreshed {
|
||||||
lastRefreshed = state.LastRefreshed.Unix()
|
lastRefreshed = state.LastRefreshed.Unix()
|
||||||
}
|
}
|
||||||
@@ -78,7 +75,7 @@ func (s *Server) feverAuth(c *router.Context) bool {
|
|||||||
if s.Username != "" && s.Password != "" {
|
if s.Username != "" && s.Password != "" {
|
||||||
apiKey := c.Req.FormValue("api_key")
|
apiKey := c.Req.FormValue("api_key")
|
||||||
apiKey = strings.ToLower(apiKey)
|
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[:])
|
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
|
||||||
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
|
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
|
||||||
return false
|
return false
|
||||||
@@ -97,7 +94,7 @@ func formHasValue(values url.Values, value string) bool {
|
|||||||
func (s *Server) handleFever(c *router.Context) {
|
func (s *Server) handleFever(c *router.Context) {
|
||||||
c.Req.ParseForm()
|
c.Req.ParseForm()
|
||||||
if !s.feverAuth(c) {
|
if !s.feverAuth(c) {
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"api_version": 3,
|
"api_version": 3,
|
||||||
"auth": 0,
|
"auth": 0,
|
||||||
"last_refreshed_on_time": 0,
|
"last_refreshed_on_time": 0,
|
||||||
@@ -123,10 +120,11 @@ func (s *Server) handleFever(c *router.Context) {
|
|||||||
case formHasValue(c.Req.Form, "mark"):
|
case formHasValue(c.Req.Form, "mark"):
|
||||||
s.feverMarkHandler(c)
|
s.feverMarkHandler(c)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
states, _ := s.db.ListFeedStates()
|
||||||
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"api_version": 3,
|
"api_version": 3,
|
||||||
"auth": 1,
|
"auth": 1,
|
||||||
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
|
"last_refreshed_on_time": getLastRefreshedOnTime(states),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,20 +166,25 @@ func (s *Server) feverGroupsHandler(c *router.Context) {
|
|||||||
for i, folder := range folders {
|
for i, folder := range folders {
|
||||||
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
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,
|
"groups": groups,
|
||||||
"feeds_groups": feedGroups(s.db),
|
"feeds_groups": feedGroups(s.db),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverFeedsHandler(c *router.Context) {
|
func (s *Server) feverFeedsHandler(c *router.Context) {
|
||||||
feeds := s.db.ListFeeds()
|
feeds := s.db.ListFeeds()
|
||||||
httpStates := s.db.ListHTTPStates()
|
states, _ := s.db.ListFeedStates()
|
||||||
|
statesMap := make(map[int64]storage.FeedState)
|
||||||
|
for _, state := range states {
|
||||||
|
statesMap[state.FeedID] = state
|
||||||
|
}
|
||||||
|
|
||||||
feverFeeds := make([]*FeverFeed, len(feeds))
|
feverFeeds := make([]*FeverFeed, len(feeds))
|
||||||
for i, feed := range feeds {
|
for i, feed := range feeds {
|
||||||
var lastUpdated int64
|
var lastUpdated int64
|
||||||
if state, ok := httpStates[feed.Id]; ok {
|
if state, ok := statesMap[feed.Id]; ok {
|
||||||
lastUpdated = state.LastRefreshed.Unix()
|
lastUpdated = state.LastRefreshed.Unix()
|
||||||
}
|
}
|
||||||
feverFeeds[i] = &FeverFeed{
|
feverFeeds[i] = &FeverFeed{
|
||||||
@@ -194,10 +197,10 @@ func (s *Server) feverFeedsHandler(c *router.Context) {
|
|||||||
LastUpdated: lastUpdated,
|
LastUpdated: lastUpdated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"feeds": feverFeeds,
|
"feeds": feverFeeds,
|
||||||
"feeds_groups": feedGroups(s.db),
|
"feeds_groups": feedGroups(s.db),
|
||||||
}, getLastRefreshedOnTime(httpStates))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverFaviconsHandler(c *router.Context) {
|
func (s *Server) feverFaviconsHandler(c *router.Context) {
|
||||||
@@ -216,9 +219,10 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
|
|||||||
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
states, _ := s.db.ListFeedStates()
|
||||||
|
writeFeverJSON(c, map[string]any{
|
||||||
"favicons": favicons,
|
"favicons": favicons,
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
// for memory pressure reasons, we only return a limited number of items
|
// for memory pressure reasons, we only return a limited number of items
|
||||||
@@ -278,18 +282,20 @@ 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,
|
"items": feverItems,
|
||||||
"total_items": totalItems,
|
"total_items": totalItems,
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverLinksHandler(c *router.Context) {
|
func (s *Server) feverLinksHandler(c *router.Context) {
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
states, _ := s.db.ListFeedStates()
|
||||||
"links": make([]interface{}, 0),
|
writeFeverJSON(c, map[string]any{
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
"links": make([]any, 0),
|
||||||
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
||||||
@@ -309,9 +315,10 @@ func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
|||||||
}
|
}
|
||||||
itemFilter.After = &items[len(items)-1].Id
|
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),
|
"unread_item_ids": joinInts(itemIds),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
||||||
@@ -331,9 +338,10 @@ func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
|||||||
}
|
}
|
||||||
itemFilter.After = &items[len(items)-1].Id
|
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),
|
"saved_item_ids": joinInts(itemIds),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverMarkHandler(c *router.Context) {
|
func (s *Server) feverMarkHandler(c *router.Context) {
|
||||||
@@ -375,7 +383,10 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
|||||||
if c.Req.Form.Get("as") != "read" {
|
if c.Req.Form.Get("as") != "read" {
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
markFilter := storage.MarkFilter{FolderID: &id}
|
markFilter := storage.MarkFilter{}
|
||||||
|
if id > 0 {
|
||||||
|
markFilter.FolderID = &id
|
||||||
|
}
|
||||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||||
if x > 0 {
|
if x > 0 {
|
||||||
before := time.Unix(x, 0).UTC()
|
before := time.Unix(x, 0).UTC()
|
||||||
@@ -386,7 +397,7 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
|||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"api_version": 3,
|
"api_version": 3,
|
||||||
"auth": 1,
|
"auth": 1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (c *Context) Next() {
|
|||||||
c.chain[c.index](c)
|
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)
|
body, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -35,7 +35,7 @@ func (c *Context) JSON(status int, data interface{}) {
|
|||||||
c.Out.Write([]byte("\n"))
|
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.Header().Set("Content-Type", "text/html")
|
||||||
c.Out.WriteHeader(status)
|
c.Out.WriteHeader(status)
|
||||||
tmpl.Execute(c.Out, data)
|
tmpl.Execute(c.Out, data)
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ func (s *Server) handler() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleIndex(c *router.Context) {
|
func (s *Server) handleIndex(c *router.Context) {
|
||||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]interface{}{
|
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
||||||
"settings": s.db.GetSettings(),
|
"settings": s.db.GetSettings().Map(),
|
||||||
"authenticated": s.Username != "" && s.Password != "",
|
"authenticated": s.Username != "" && s.Password != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,14 +82,14 @@ func (s *Server) handleStatic(c *router.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleManifest(c *router.Context) {
|
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",
|
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||||
"name": "yarr!",
|
"name": "yarr!",
|
||||||
"short_name": "yarr",
|
"short_name": "yarr",
|
||||||
"description": "yet another rss reader",
|
"description": "yet another rss reader",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
||||||
"icons": []map[string]interface{}{
|
"icons": []map[string]any{
|
||||||
{
|
{
|
||||||
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
||||||
"sizes": "64x64",
|
"sizes": "64x64",
|
||||||
@@ -100,7 +100,7 @@ func (s *Server) handleManifest(c *router.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStatus(c *router.Context) {
|
func (s *Server) handleStatus(c *router.Context) {
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"running": s.worker.FeedsPending(),
|
"running": s.worker.FeedsPending(),
|
||||||
"stats": s.db.FeedStats(),
|
"stats": s.db.FeedStats(),
|
||||||
})
|
})
|
||||||
@@ -141,12 +141,10 @@ func (s *Server) handleFolder(c *router.Context) {
|
|||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
s.db.UpdateFolder(id, storage.UpdateFolderParams{
|
||||||
s.db.RenameFolder(id, *body.Title)
|
Title: body.Title,
|
||||||
}
|
IsExpanded: body.IsExpanded,
|
||||||
if body.IsExpanded != nil {
|
})
|
||||||
s.db.ToggleFolderExpanded(id, *body.IsExpanded)
|
|
||||||
}
|
|
||||||
c.Out.WriteHeader(http.StatusOK)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
} else if c.Req.Method == "DELETE" {
|
} else if c.Req.Method == "DELETE" {
|
||||||
s.db.DeleteFolder(id)
|
s.db.DeleteFolder(id)
|
||||||
@@ -164,7 +162,15 @@ func (s *Server) handleFeedRefresh(c *router.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleFeedErrors(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)
|
c.JSON(http.StatusOK, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,25 +245,22 @@ func (s *Server) handleFeedList(c *router.Context) {
|
|||||||
case len(result.Sources) > 0:
|
case len(result.Sources) > 0:
|
||||||
c.JSON(
|
c.JSON(
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
map[string]interface{}{"status": "multiple", "choice": result.Sources},
|
map[string]any{"status": "multiple", "choice": result.Sources},
|
||||||
)
|
)
|
||||||
case result.Feed != nil:
|
case result.Feed != nil:
|
||||||
feed := s.db.CreateFeed(
|
feed := s.db.CreateFeed(storage.CreateFeedParams{
|
||||||
result.Feed.Title,
|
Title: result.Feed.Title,
|
||||||
"",
|
Link: result.Feed.SiteURL,
|
||||||
result.Feed.SiteURL,
|
FeedLink: result.FeedLink,
|
||||||
result.FeedLink,
|
FolderID: form.FolderID,
|
||||||
form.FolderID,
|
})
|
||||||
)
|
|
||||||
items := worker.ConvertItems(result.Feed.Items, *feed)
|
items := worker.ConvertItems(result.Feed.Items, *feed)
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
s.db.CreateItems(items)
|
s.db.CreateItems(items)
|
||||||
s.db.SetFeedSize(feed.Id, len(items))
|
|
||||||
s.db.SyncSearch()
|
|
||||||
}
|
}
|
||||||
s.worker.FindFeedFavicon(*feed)
|
s.worker.FindFeedFavicon(*feed)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"feed": feed,
|
"feed": feed,
|
||||||
})
|
})
|
||||||
@@ -279,30 +282,34 @@ func (s *Server) handleFeed(c *router.Context) {
|
|||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body := make(map[string]interface{})
|
body := make(map[string]any)
|
||||||
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
params := storage.UpdateFeedParams{}
|
||||||
if title, ok := body["title"]; ok {
|
if title, ok := body["title"]; ok {
|
||||||
if reflect.TypeOf(title).Kind() == reflect.String {
|
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, ok := body["folder_id"]; ok {
|
||||||
if f_id == nil {
|
if f_id == nil {
|
||||||
s.db.UpdateFeedFolder(id, nil)
|
params.FolderID = storage.SetNullable[int64](nil)
|
||||||
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
|
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
|
||||||
folderId := int64(f_id.(float64))
|
folderId := int64(f_id.(float64))
|
||||||
s.db.UpdateFeedFolder(id, &folderId)
|
params.FolderID = storage.SetNullable(&folderId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if link, ok := body["feed_link"]; ok {
|
if link, ok := body["feed_link"]; ok {
|
||||||
if reflect.TypeOf(link).Kind() == reflect.String {
|
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)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
} else if c.Req.Method == "DELETE" {
|
} else if c.Req.Method == "DELETE" {
|
||||||
s.db.DeleteFeed(id)
|
s.db.DeleteFeed(id)
|
||||||
@@ -391,7 +398,7 @@ func (s *Server) handleItemList(c *router.Context) {
|
|||||||
items[i].Title = htmlutil.TruncateText(text, 140)
|
items[i].Title = htmlutil.TruncateText(text, 140)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"list": items,
|
"list": items,
|
||||||
"has_more": hasMore,
|
"has_more": hasMore,
|
||||||
})
|
})
|
||||||
@@ -415,14 +422,14 @@ func (s *Server) handleSettings(c *router.Context) {
|
|||||||
if c.Req.Method == "GET" {
|
if c.Req.Method == "GET" {
|
||||||
c.JSON(http.StatusOK, s.db.GetSettings())
|
c.JSON(http.StatusOK, s.db.GetSettings())
|
||||||
} else if c.Req.Method == "PUT" {
|
} else if c.Req.Method == "PUT" {
|
||||||
settings := make(map[string]interface{})
|
var params storage.UpdateSettingsParams
|
||||||
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
|
if err := json.NewDecoder(c.Req.Body).Decode(¶ms); err != nil {
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.db.UpdateSettings(settings) {
|
if s.db.UpdateSettings(params) {
|
||||||
if _, ok := settings["refresh_rate"]; ok {
|
if params.RefreshRate != nil {
|
||||||
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate"))
|
s.worker.SetRefreshRate(s.db.GetSettings().RefreshRate)
|
||||||
}
|
}
|
||||||
c.Out.WriteHeader(http.StatusOK)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
} else {
|
} else {
|
||||||
@@ -445,16 +452,24 @@ func (s *Server) handleOPMLImport(c *router.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, f := range doc.Feeds {
|
for _, f := range doc.Feeds {
|
||||||
s.db.CreateFeed(f.Title, "", f.SiteUrl, f.FeedUrl, nil)
|
s.db.CreateFeed(storage.CreateFeedParams{
|
||||||
|
Title: f.Title,
|
||||||
|
Link: f.SiteUrl,
|
||||||
|
FeedLink: f.FeedUrl,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
for _, f := range doc.Folders {
|
for _, f := range doc.Folders {
|
||||||
folder := s.db.CreateFolder(f.Title)
|
folder := s.db.CreateFolder(f.Title)
|
||||||
for _, ff := range f.AllFeeds() {
|
for _, ff := range f.AllFeeds() {
|
||||||
s.db.CreateFeed(ff.Title, "", ff.SiteUrl, ff.FeedUrl, &folder.Id)
|
s.db.CreateFeed(storage.CreateFeedParams{
|
||||||
|
Title: ff.Title,
|
||||||
|
Link: ff.SiteUrl,
|
||||||
|
FeedLink: ff.FeedUrl,
|
||||||
|
FolderID: &folder.Id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.worker.FindFavicons()
|
|
||||||
s.worker.RefreshFeeds()
|
s.worker.RefreshFeeds()
|
||||||
|
|
||||||
c.Out.WriteHeader(http.StatusOK)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
@@ -472,7 +487,6 @@ func (s *Server) handleOPMLExport(c *router.Context) {
|
|||||||
|
|
||||||
feedsByFolderID := make(map[int64][]*storage.Feed)
|
feedsByFolderID := make(map[int64][]*storage.Feed)
|
||||||
for _, feed := range s.db.ListFeeds() {
|
for _, feed := range s.db.ListFeeds() {
|
||||||
feed := feed
|
|
||||||
if feed.FolderId == nil {
|
if feed.FolderId == nil {
|
||||||
doc.Feeds = append(doc.Feeds, opml.Feed{
|
doc.Feeds = append(doc.Feeds, opml.Feed{
|
||||||
Title: feed.Title,
|
Title: feed.Title,
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ func TestFeedIcons(t *testing.T) {
|
|||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
db, _ := storage.New(":memory:")
|
db, _ := storage.New(":memory:")
|
||||||
icon := []byte("test")
|
icon := []byte("test")
|
||||||
feed := db.CreateFeed("", "", "", "", nil)
|
feed := db.CreateFeed(storage.CreateFeedParams{})
|
||||||
db.UpdateFeedIcon(feed.Id, &icon)
|
db.UpdateFeed(feed.Id, storage.UpdateFeedParams{Icon: storage.SetNullable(&icon)})
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Server struct {
|
|||||||
Addr string
|
Addr string
|
||||||
db *storage.Storage
|
db *storage.Storage
|
||||||
worker *worker.Worker
|
worker *worker.Worker
|
||||||
cache map[string]interface{}
|
cache map[string]any
|
||||||
cache_mutex *sync.Mutex
|
cache_mutex *sync.Mutex
|
||||||
|
|
||||||
BasePath string
|
BasePath string
|
||||||
@@ -34,7 +34,7 @@ func NewServer(db *storage.Storage, addr string) *Server {
|
|||||||
db: db,
|
db: db,
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
worker: worker.NewWorker(db),
|
worker: worker.NewWorker(db),
|
||||||
cache: make(map[string]interface{}),
|
cache: make(map[string]any),
|
||||||
cache_mutex: &sync.Mutex{},
|
cache_mutex: &sync.Mutex{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,8 +48,7 @@ func (h *Server) GetAddr() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Start() {
|
func (s *Server) Start() {
|
||||||
refreshRate := s.db.GetSettingsValueInt64("refresh_rate")
|
refreshRate := s.db.GetSettings().RefreshRate
|
||||||
s.worker.FindFavicons()
|
|
||||||
s.worker.StartFeedCleaner()
|
s.worker.StartFeedCleaner()
|
||||||
s.worker.SetRefreshRate(refreshRate)
|
s.worker.SetRefreshRate(refreshRate)
|
||||||
if refreshRate > 0 {
|
if refreshRate > 0 {
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ type Feed struct {
|
|||||||
HasIcon bool `json:"has_icon"`
|
HasIcon bool `json:"has_icon"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId *int64) *Feed {
|
type CreateFeedParams struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Link string
|
||||||
|
FeedLink string
|
||||||
|
FolderID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CreateFeed(params CreateFeedParams) *Feed {
|
||||||
|
title := params.Title
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = feedLink
|
title = params.FeedLink
|
||||||
}
|
}
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
insert into feeds (title, description, link, feed_link, folder_id)
|
insert into feeds (title, description, link, feed_link, folder_id)
|
||||||
@@ -26,10 +35,10 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
|
|||||||
on conflict (feed_link) do update set folder_id = :folder_id
|
on conflict (feed_link) do update set folder_id = :folder_id
|
||||||
returning id`,
|
returning id`,
|
||||||
sql.Named("title", title),
|
sql.Named("title", title),
|
||||||
sql.Named("description", description),
|
sql.Named("description", params.Description),
|
||||||
sql.Named("link", link),
|
sql.Named("link", params.Link),
|
||||||
sql.Named("feed_link", feedLink),
|
sql.Named("feed_link", params.FeedLink),
|
||||||
sql.Named("folder_id", folderId),
|
sql.Named("folder_id", params.FolderID),
|
||||||
)
|
)
|
||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
@@ -41,10 +50,10 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
|
|||||||
return &Feed{
|
return &Feed{
|
||||||
Id: id,
|
Id: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: params.Description,
|
||||||
Link: link,
|
Link: params.Link,
|
||||||
FeedLink: feedLink,
|
FeedLink: params.FeedLink,
|
||||||
FolderId: folderId,
|
FolderId: params.FolderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,36 +73,35 @@ func (s *Storage) DeleteFeed(feedId int64) bool {
|
|||||||
return nrows == 1
|
return nrows == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) RenameFeed(feedId int64, newTitle string) bool {
|
type UpdateFeedParams struct {
|
||||||
_, err := s.db.Exec(`update feeds set title = :title where id = :id`,
|
Title *string
|
||||||
sql.Named("title", newTitle),
|
FeedLink *string
|
||||||
sql.Named("id", feedId),
|
FolderID Nullable[int64]
|
||||||
)
|
Icon Nullable[[]byte]
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) UpdateFeedFolder(feedId int64, newFolderId *int64) bool {
|
func (s *Storage) UpdateFeed(feedId int64, params UpdateFeedParams) (bool, error) {
|
||||||
_, err := s.db.Exec(`update feeds set folder_id = :folder_id where id = :id`,
|
_, err := s.db.Exec(`
|
||||||
sql.Named("folder_id", newFolderId),
|
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("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),
|
||||||
)
|
)
|
||||||
return err == nil
|
if err != nil {
|
||||||
}
|
log.Print(err)
|
||||||
|
return false, err
|
||||||
func (s *Storage) UpdateFeedLink(feedId int64, newLink string) bool {
|
}
|
||||||
_, err := s.db.Exec(`update feeds set feed_link = :feed_link where id = :id`,
|
return true, nil
|
||||||
sql.Named("feed_link", newLink),
|
|
||||||
sql.Named("id", feedId),
|
|
||||||
)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
|
|
||||||
_, err := s.db.Exec(`update feeds set icon = :icon where id = :id`,
|
|
||||||
sql.Named("icon", icon),
|
|
||||||
sql.Named("id", feedId),
|
|
||||||
)
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ListFeeds() []Feed {
|
func (s *Storage) ListFeeds() []Feed {
|
||||||
@@ -128,36 +136,6 @@ func (s *Storage) ListFeeds() []Feed {
|
|||||||
return result
|
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 {
|
func (s *Storage) GetFeed(id int64) *Feed {
|
||||||
var f Feed
|
var f Feed
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
@@ -177,55 +155,3 @@ func (s *Storage) GetFeed(id int64) *Feed {
|
|||||||
}
|
}
|
||||||
return &f
|
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 (:feed_id, :error)
|
|
||||||
on conflict (feed_id) do update set error = excluded.error`,
|
|
||||||
sql.Named("feed_id", feedID),
|
|
||||||
sql.Named("error", 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 (:feed_id, :size)
|
|
||||||
on conflict (feed_id) do update set size = excluded.size`,
|
|
||||||
sql.Named("feed_id", feedId),
|
|
||||||
sql.Named("size", size),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func TestCreateFeed(t *testing.T) {
|
func TestCreateFeed(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example.com/feed.xml"})
|
||||||
if feed1 == nil || feed1.Id == 0 {
|
if feed1 == nil || feed1.Id == 0 {
|
||||||
t.Fatal("expected feed")
|
t.Fatal("expected feed")
|
||||||
}
|
}
|
||||||
@@ -19,16 +19,16 @@ func TestCreateFeed(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateFeedSameLink(t *testing.T) {
|
func TestCreateFeedSameLink(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("title", "", "", "http://example1.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "title", FeedLink: "http://example1.com/feed.xml"})
|
||||||
if feed1 == nil || feed1.Id == 0 {
|
if feed1 == nil || feed1.Id == 0 {
|
||||||
t.Fatal("expected feed")
|
t.Fatal("expected feed")
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
|
db.CreateFeed(CreateFeedParams{Title: "title", FeedLink: "http://example2.com/feed.xml"})
|
||||||
}
|
}
|
||||||
|
|
||||||
feed2 := db.CreateFeed("title", "", "http://example.com", "http://example1.com/feed.xml", nil)
|
feed2 := db.CreateFeed(CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example1.com/feed.xml"})
|
||||||
if feed1.Id != feed2.Id {
|
if feed1.Id != feed2.Id {
|
||||||
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
|
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
|
||||||
}
|
}
|
||||||
@@ -40,8 +40,8 @@ func TestReadFeed(t *testing.T) {
|
|||||||
t.Fatal("cannot get nonexistent feed")
|
t.Fatal("cannot get nonexistent feed")
|
||||||
}
|
}
|
||||||
|
|
||||||
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "feed 1", Link: "http://example1.com", FeedLink: "http://example1.com/feed.xml"})
|
||||||
feed2 := db.CreateFeed("feed 2", "", "http://example2.com", "http://example2.com/feed.xml", nil)
|
feed2 := db.CreateFeed(CreateFeedParams{Title: "feed 2", Link: "http://example2.com", FeedLink: "http://example2.com/feed.xml"})
|
||||||
feeds := db.ListFeeds()
|
feeds := db.ListFeeds()
|
||||||
if !reflect.DeepEqual(feeds, []Feed{*feed1, *feed2}) {
|
if !reflect.DeepEqual(feeds, []Feed{*feed1, *feed2}) {
|
||||||
t.Fatalf("invalid feed list: %#v", feeds)
|
t.Fatalf("invalid feed list: %#v", feeds)
|
||||||
@@ -50,13 +50,16 @@ func TestReadFeed(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateFeed(t *testing.T) {
|
func TestUpdateFeed(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "feed 1", Link: "http://example1.com", FeedLink: "http://example1.com/feed.xml"})
|
||||||
folder := db.CreateFolder("test")
|
folder := db.CreateFolder("test")
|
||||||
icon := []byte("icon")
|
icon := []byte("icon")
|
||||||
|
|
||||||
db.RenameFeed(feed1.Id, "newtitle")
|
title := "newtitle"
|
||||||
db.UpdateFeedFolder(feed1.Id, &folder.Id)
|
db.UpdateFeed(feed1.Id, UpdateFeedParams{
|
||||||
db.UpdateFeedIcon(feed1.Id, &icon)
|
Title: &title,
|
||||||
|
FolderID: SetNullable(&folder.Id),
|
||||||
|
Icon: SetNullable(&icon),
|
||||||
|
})
|
||||||
|
|
||||||
feed2 := db.GetFeed(feed1.Id)
|
feed2 := db.GetFeed(feed1.Id)
|
||||||
if feed2.Title != "newtitle" {
|
if feed2.Title != "newtitle" {
|
||||||
@@ -72,7 +75,7 @@ func TestUpdateFeed(t *testing.T) {
|
|||||||
|
|
||||||
func TestDeleteFeed(t *testing.T) {
|
func TestDeleteFeed(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example.com/feed.xml"})
|
||||||
|
|
||||||
if db.DeleteFeed(100500) {
|
if db.DeleteFeed(100500) {
|
||||||
t.Error("cannot delete what does not exist")
|
t.Error("cannot delete what does not exist")
|
||||||
|
|||||||
119
src/storage/feedstate.go
Normal file
119
src/storage/feedstate.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FeedState struct {
|
||||||
|
FeedID int64
|
||||||
|
LastRefreshed time.Time
|
||||||
|
LastError string
|
||||||
|
HTTPLastModified string
|
||||||
|
HTTPEtag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ListFeedStates() ([]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([]FeedState, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var state 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 *Storage) GetFeedState(feedID int64) (*FeedState, error) {
|
||||||
|
var state 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
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFeedStateParams struct {
|
||||||
|
LastRefreshed *time.Time
|
||||||
|
LastError *string
|
||||||
|
HTTPLastModified *string
|
||||||
|
HTTPEtag *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateFeedState(feedID int64, params 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
|
||||||
|
}
|
||||||
129
src/storage/feedstate_test.go
Normal file
129
src/storage/feedstate_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateFeedState_Full(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f := s.CreateFeed(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, 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) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
|
||||||
|
etag := "v1"
|
||||||
|
s.UpdateFeedState(f.Id, UpdateFeedStateParams{HTTPEtag: &etag})
|
||||||
|
|
||||||
|
newErr := "new error"
|
||||||
|
_, err := s.UpdateFeedState(f.Id, 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) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
|
||||||
|
errMsg := "error"
|
||||||
|
s.UpdateFeedState(f.Id, UpdateFeedStateParams{LastError: &errMsg})
|
||||||
|
|
||||||
|
empty := ""
|
||||||
|
_, err := s.UpdateFeedState(f.Id, 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) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f1 := s.CreateFeed(CreateFeedParams{Title: "F1", FeedLink: "L1"})
|
||||||
|
f2 := s.CreateFeed(CreateFeedParams{Title: "F2", FeedLink: "L2"})
|
||||||
|
|
||||||
|
errMsg := "fail"
|
||||||
|
s.UpdateFeedState(f1.Id, UpdateFeedStateParams{LastError: &errMsg})
|
||||||
|
s.UpdateFeedState(f2.Id, 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
|
||||||
|
}
|
||||||
@@ -38,20 +38,27 @@ func (s *Storage) DeleteFolder(folderId int64) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) RenameFolder(folderId int64, newTitle string) bool {
|
type UpdateFolderParams struct {
|
||||||
_, err := s.db.Exec(`update folders set title = :title where id = :id`,
|
Title *string
|
||||||
sql.Named("title", newTitle),
|
IsExpanded *bool
|
||||||
sql.Named("id", folderId),
|
|
||||||
)
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ToggleFolderExpanded(folderId int64, isExpanded bool) bool {
|
func (s *Storage) UpdateFolder(folderId int64, params UpdateFolderParams) (bool, error) {
|
||||||
_, err := s.db.Exec(`update folders set is_expanded = :is_expanded where id = :id`,
|
_, err := s.db.Exec(`
|
||||||
sql.Named("is_expanded", isExpanded),
|
update folders set
|
||||||
|
title = coalesce(:title, title),
|
||||||
|
is_expanded = coalesce(:is_expanded, is_expanded)
|
||||||
|
where id = :id
|
||||||
|
`,
|
||||||
sql.Named("id", folderId),
|
sql.Named("id", folderId),
|
||||||
|
sql.Named("title", params.Title),
|
||||||
|
sql.Named("is_expanded", params.IsExpanded),
|
||||||
)
|
)
|
||||||
return err == nil
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ListFolders() []Folder {
|
func (s *Storage) ListFolders() []Folder {
|
||||||
|
|||||||
78
src/storage/folder_test.go
Normal file
78
src/storage/folder_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateFolder(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
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, 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, 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, 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, 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"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 = :feed_id
|
|
||||||
`, sql.Named("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 (:feed_id, :last_modified, :etag, datetime())
|
|
||||||
on conflict (feed_id) do update set last_modified = :last_modified, etag = :etag, last_refreshed = datetime()`,
|
|
||||||
sql.Named("feed_id", feedID),
|
|
||||||
sql.Named("last_modified", lastModified),
|
|
||||||
sql.Named("etag", etag),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ItemStatus int
|
type ItemStatus int
|
||||||
@@ -135,14 +133,15 @@ func (s *Storage) CreateItems(items []Item) bool {
|
|||||||
insert into items (
|
insert into items (
|
||||||
guid, feed_id, title, link, date,
|
guid, feed_id, title, link, date,
|
||||||
content, media_links,
|
content, media_links,
|
||||||
date_arrived, status
|
date_arrived, last_arrived, status
|
||||||
)
|
)
|
||||||
values (
|
values (
|
||||||
:guid, :feed_id, :title, :link, strftime('%Y-%m-%d %H:%M:%f', :date),
|
:guid, :feed_id, :title, :link, strftime('%Y-%m-%d %H:%M:%f', :date),
|
||||||
:content, :media_links,
|
:content, :media_links,
|
||||||
:date_arrived, :status
|
:date_arrived, :last_arrived, :status
|
||||||
)
|
)
|
||||||
on conflict (feed_id, guid) do nothing`,
|
on conflict (feed_id, guid) do update set
|
||||||
|
last_arrived = :last_arrived`,
|
||||||
sql.Named("guid", item.GUID),
|
sql.Named("guid", item.GUID),
|
||||||
sql.Named("feed_id", item.FeedId),
|
sql.Named("feed_id", item.FeedId),
|
||||||
sql.Named("title", item.Title),
|
sql.Named("title", item.Title),
|
||||||
@@ -151,6 +150,7 @@ func (s *Storage) CreateItems(items []Item) bool {
|
|||||||
sql.Named("content", item.Content),
|
sql.Named("content", item.Content),
|
||||||
sql.Named("media_links", item.MediaLinks),
|
sql.Named("media_links", item.MediaLinks),
|
||||||
sql.Named("date_arrived", now),
|
sql.Named("date_arrived", now),
|
||||||
|
sql.Named("last_arrived", now),
|
||||||
sql.Named("status", UNREAD),
|
sql.Named("status", UNREAD),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,9 +169,9 @@ func (s *Storage) CreateItems(items []Item) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interface{}) {
|
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []any) {
|
||||||
cond := make([]string, 0)
|
cond := make([]string, 0)
|
||||||
args := make([]interface{}, 0)
|
args := make([]any, 0)
|
||||||
if filter.FolderID != nil {
|
if filter.FolderID != nil {
|
||||||
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = :folder_id)")
|
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))
|
args = append(args, sql.Named("folder_id", *filter.FolderID))
|
||||||
@@ -193,7 +193,7 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
|
|||||||
|
|
||||||
cond = append(
|
cond = append(
|
||||||
cond,
|
cond,
|
||||||
"i.search_rowid in (select rowid from search where search match :search)",
|
"i.id in (select rowid as id from search where search match :search)",
|
||||||
)
|
)
|
||||||
args = append(args, sql.Named("search", strings.Join(terms, " ")))
|
args = append(args, sql.Named("search", strings.Join(terms, " ")))
|
||||||
}
|
}
|
||||||
@@ -241,16 +241,9 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
|
|||||||
return predicate, args
|
return predicate, args
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) CountItems(filter ItemFilter) int {
|
func (s *Storage) CountItems() int {
|
||||||
predicate, args := listQueryPredicate(filter, false)
|
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
query := fmt.Sprintf(`
|
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
|
||||||
select count(*)
|
|
||||||
from items
|
|
||||||
where %s
|
|
||||||
`, predicate)
|
|
||||||
err := s.db.QueryRow(query, args...).Scan(&count)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return 0
|
return 0
|
||||||
@@ -384,46 +377,6 @@ func (s *Storage) FeedStats() []FeedStat {
|
|||||||
return result
|
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 (:title, "", :content)`,
|
|
||||||
sql.Named("title", item.Title),
|
|
||||||
sql.Named("content", 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 = :search_rowid where id = :id`,
|
|
||||||
sql.Named("search_rowid", rowId),
|
|
||||||
sql.Named("id", item.Id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
itemsKeepSize = 50
|
itemsKeepSize = 50
|
||||||
itemsKeepDays = 90
|
itemsKeepDays = 90
|
||||||
@@ -433,67 +386,39 @@ var (
|
|||||||
//
|
//
|
||||||
// The rules:
|
// The rules:
|
||||||
// - Never delete starred entries.
|
// - Never delete starred entries.
|
||||||
// - Keep at least the same amount of articles the feed provides (default: 50).
|
// - Keep at least 50 latest items for each feed.
|
||||||
// This prevents from deleting items for rarely updated and/or ever-growing
|
// - Delete entries older than 90 days relative to the latest arrived item in the same feed.
|
||||||
// feeds which might eventually reappear as unread.
|
|
||||||
// - Keep entries for a certain period (default: 90 days).
|
|
||||||
func (s *Storage) DeleteOldItems() {
|
func (s *Storage) DeleteOldItems() {
|
||||||
rows, err := s.db.Query(`
|
result, err := s.db.Exec(`
|
||||||
select
|
delete from items
|
||||||
i.feed_id,
|
where id in (
|
||||||
max(coalesce(s.size, 0), :keep_size) as max_items,
|
select id
|
||||||
count(*) as num_items
|
from (
|
||||||
from items i
|
select
|
||||||
left outer join feed_sizes s on s.feed_id = i.feed_id
|
id,
|
||||||
where status != :starred_status
|
row_number() over (partition by feed_id order by date desc) as rn,
|
||||||
group by i.feed_id
|
last_arrived,
|
||||||
`,
|
max(last_arrived) over (partition by feed_id) as max_la
|
||||||
sql.Named("keep_size", itemsKeepSize),
|
from items
|
||||||
|
where status != :starred_status
|
||||||
|
)
|
||||||
|
where rn > :keep_size
|
||||||
|
and last_arrived < datetime(max_la, :keep_days_limit)
|
||||||
|
)`,
|
||||||
sql.Named("starred_status", STARRED),
|
sql.Named("starred_status", STARRED),
|
||||||
|
sql.Named("keep_size", itemsKeepSize),
|
||||||
|
sql.Named("keep_days_limit", fmt.Sprintf("-%d days", itemsKeepDays)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
numDeleted, err := result.RowsAffected()
|
||||||
|
if err == nil && numDeleted > 0 {
|
||||||
|
log.Printf("Deleted %d old items", numDeleted)
|
||||||
|
|
||||||
feedLimits := make(map[int64]int64, 0)
|
if _, err := s.db.Exec("vacuum"); err != nil {
|
||||||
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 = :feed_id and status != :starred_status
|
|
||||||
order by date desc
|
|
||||||
limit -1 offset :limit
|
|
||||||
) and date_arrived < :date_limit
|
|
||||||
`,
|
|
||||||
sql.Named("feed_id", feedId),
|
|
||||||
sql.Named("starred_status", STARRED),
|
|
||||||
sql.Named("limit", limit),
|
|
||||||
sql.Named(
|
|
||||||
"date_limit",
|
|
||||||
time.Now().UTC().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,10 +39,10 @@ func testItemsSetup(db *Storage) testItemScope {
|
|||||||
folder1 := db.CreateFolder("folder1")
|
folder1 := db.CreateFolder("folder1")
|
||||||
folder2 := db.CreateFolder("folder2")
|
folder2 := db.CreateFolder("folder2")
|
||||||
|
|
||||||
feed11 := db.CreateFeed("feed11", "", "", "http://test.com/feed11.xml", &folder1.Id)
|
feed11 := db.CreateFeed(CreateFeedParams{Title: "feed11", FeedLink: "http://test.com/feed11.xml", FolderID: &folder1.Id})
|
||||||
feed12 := db.CreateFeed("feed12", "", "", "http://test.com/feed12.xml", &folder1.Id)
|
feed12 := db.CreateFeed(CreateFeedParams{Title: "feed12", FeedLink: "http://test.com/feed12.xml", FolderID: &folder1.Id})
|
||||||
feed21 := db.CreateFeed("feed21", "", "", "http://test.com/feed21.xml", &folder2.Id)
|
feed21 := db.CreateFeed(CreateFeedParams{Title: "feed21", FeedLink: "http://test.com/feed21.xml", FolderID: &folder2.Id})
|
||||||
feed01 := db.CreateFeed("feed01", "", "", "http://test.com/feed01.xml", nil)
|
feed01 := db.CreateFeed(CreateFeedParams{Title: "feed01", FeedLink: "http://test.com/feed01.xml"})
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
db.CreateItems([]Item{
|
db.CreateItems([]Item{
|
||||||
@@ -211,7 +212,6 @@ func TestListItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// filter by search
|
// filter by search
|
||||||
db.SyncSearch()
|
|
||||||
search1 := "title111"
|
search1 := "title111"
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
|
||||||
want = []string{"item111"}
|
want = []string{"item111"}
|
||||||
@@ -320,57 +320,196 @@ func TestMarkItemsRead(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteOldItems(t *testing.T) {
|
func TestDeleteOldItems(t *testing.T) {
|
||||||
extraItems := 10
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
db := testDB()
|
starred := STARRED
|
||||||
feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil)
|
|
||||||
|
|
||||||
items := make([]Item, 0)
|
t.Run("keeps at least 50 items", func(t *testing.T) {
|
||||||
for i := 0; i < itemsKeepSize+extraItems; i++ {
|
db := testDB()
|
||||||
istr := strconv.Itoa(i)
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
items = append(items, Item{
|
items := make([]Item, 100)
|
||||||
GUID: istr,
|
for i := range 100 {
|
||||||
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Hour * 24)}
|
||||||
|
}
|
||||||
|
db.CreateItems(items)
|
||||||
|
|
||||||
|
// // Set 1 recent (latest), 100 old (100 days ago)
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid != "99"`, sql.Named("la", now.Add(-time.Hour*24*100)))
|
||||||
|
|
||||||
|
db.DeleteOldItems()
|
||||||
|
var have int
|
||||||
|
db.db.QueryRow("select count(*) from items where feed_id = ?", feed.Id).Scan(&have)
|
||||||
|
if have != 50 {
|
||||||
|
t.Errorf("expected 50 items, have %d", have)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps all less than 90 days old", func(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
|
items := make([]Item, 100)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
||||||
|
}
|
||||||
|
db.CreateItems(items)
|
||||||
|
|
||||||
|
// Latest item at "now"
|
||||||
|
// All others at 80 days ago (keep)
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid != "99"`, sql.Named("la", now.Add(-time.Hour*24*80)))
|
||||||
|
|
||||||
|
db.DeleteOldItems()
|
||||||
|
var have int
|
||||||
|
db.db.QueryRow("select count(*) from items where feed_id = ?", feed.Id).Scan(&have)
|
||||||
|
if have != 100 {
|
||||||
|
t.Errorf("expected 100 items, have %d", have)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps starred", func(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
|
items := make([]Item, 100)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
items[i] = 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
|
||||||
|
db.db.Exec(`update items set last_arrived = :la`, sql.Named("la", now.Add(-time.Hour*24*100)))
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||||
|
// Star 10 old items that would otherwise be deleted (rn > 50 and old)
|
||||||
|
db.db.Exec(`update items set status = :s where cast(guid as integer) < 10`, sql.Named("s", starred))
|
||||||
|
|
||||||
|
db.DeleteOldItems()
|
||||||
|
var have int
|
||||||
|
db.db.QueryRow("select count(*) from items where feed_id = ?", feed.Id).Scan(&have)
|
||||||
|
// 50 (limit) + 10 (starred) = 60 items should remain.
|
||||||
|
if have != 60 {
|
||||||
|
t.Errorf("expected 60 items, have %d", have)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func TestCreateItemsLastArrived(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
defer db.db.Close()
|
||||||
|
feed := db.CreateFeed(CreateFeedParams{Title: "test feed", FeedLink: "http://example.com/feed"})
|
||||||
|
|
||||||
|
item := Item{
|
||||||
|
GUID: "item1",
|
||||||
FeedId: feed.Id,
|
FeedId: feed.Id,
|
||||||
Title: istr,
|
Title: "Title 1",
|
||||||
Date: now.Add(time.Hour * time.Duration(i)),
|
Date: time.Now(),
|
||||||
})
|
}
|
||||||
}
|
|
||||||
db.CreateItems(items)
|
|
||||||
|
|
||||||
db.SetFeedSize(feed.Id, itemsKeepSize)
|
// 1. Initial creation
|
||||||
var feedSize int
|
db.CreateItems([]Item{item})
|
||||||
err := db.db.QueryRow(
|
|
||||||
`select size from feed_sizes where feed_id = :feed_id`, sql.Named("feed_id", feed.Id),
|
var lastArrived1 time.Time
|
||||||
).Scan(&feedSize)
|
err := db.db.QueryRow("select last_arrived from items where guid = ?", item.GUID).Scan(&lastArrived1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if feedSize != itemsKeepSize {
|
|
||||||
t.Fatalf(
|
time.Sleep(time.Second * 10)
|
||||||
"expected feed size to get updated\nwant: %d\nhave: %d",
|
|
||||||
itemsKeepSize+extraItems,
|
// 2. Update on conflict
|
||||||
feedSize,
|
db.CreateItems([]Item{item})
|
||||||
)
|
|
||||||
|
var lastArrived2 time.Time
|
||||||
|
err = db.db.QueryRow("select last_arrived from items where guid = ?", item.GUID).Scan(&lastArrived2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lastArrived2.After(lastArrived1) {
|
||||||
|
t.Errorf("expected last_arrived to be updated. old: %v, new: %v", lastArrived1, lastArrived2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
defer db.Close()
|
||||||
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
|
|
||||||
|
db.CreateItems([]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>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. Basic search
|
||||||
|
s1 := "emergency"
|
||||||
|
have := getItemGuids(db.ListItems(ItemFilter{Search: &s1}, 10, true, false))
|
||||||
|
if !reflect.DeepEqual(have, []string{"i1"}) {
|
||||||
|
t.Errorf("basic search failed: expected [i1], got %v", have)
|
||||||
}
|
}
|
||||||
|
|
||||||
// expire only the first 3 articles
|
// 2. HTML stripping: Should find text, but NOT the tags
|
||||||
_, err = db.db.Exec(
|
s2 := "test"
|
||||||
`update items set date_arrived = :date_arrived
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s2}, 10, true, false))
|
||||||
where id in (select id from items limit 3)`,
|
if !reflect.DeepEqual(have, []string{"i1"}) {
|
||||||
sql.Named("date_arrived", now.Add(-time.Hour*time.Duration(itemsKeepDays*24))),
|
t.Errorf("html text search failed: expected [i1], got %v", have)
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.DeleteOldItems()
|
s3 := "secret-class"
|
||||||
feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false, false)
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s3}, 10, true, false))
|
||||||
if len(feedItems) != len(items)-3 {
|
if len(have) > 0 {
|
||||||
t.Fatalf(
|
t.Errorf("html tag search should have failed but found: %v", have)
|
||||||
"invalid number of old items kept\nwant: %d\nhave: %d",
|
}
|
||||||
len(items)-3,
|
|
||||||
len(feedItems),
|
// 3. Multi-word (AND)
|
||||||
)
|
s4 := "broadcast system"
|
||||||
|
have = getItemGuids(db.ListItems(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(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(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.db.Exec("update items set title = 'Updated Title' where guid = 'i1'")
|
||||||
|
s7 := "Updated"
|
||||||
|
have = getItemGuids(db.ListItems(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'")
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s7}, 10, true, false))
|
||||||
|
if len(have) > 0 {
|
||||||
|
t.Errorf("delete trigger failed: found deleted item: %v", have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ var migrations = []func(*sql.Tx) error{
|
|||||||
m08_normalize_datetime,
|
m08_normalize_datetime,
|
||||||
m09_change_item_index,
|
m09_change_item_index,
|
||||||
m10_add_item_medialinks,
|
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))
|
var maxVersion = int64(len(migrations))
|
||||||
@@ -332,3 +336,87 @@ func m10_add_item_medialinks(tx *sql.Tx) error {
|
|||||||
_, err := tx.Exec(sql)
|
_, err := tx.Exec(sql)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,91 +6,166 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func settingsDefaults() map[string]interface{} {
|
type Settings struct {
|
||||||
return map[string]interface{}{
|
Filter string `json:"filter"`
|
||||||
"filter": "",
|
Feed string `json:"feed"`
|
||||||
"feed": "",
|
FeedListWidth int `json:"feed_list_width"`
|
||||||
"feed_list_width": 300,
|
ItemListWidth int `json:"item_list_width"`
|
||||||
"item_list_width": 300,
|
SortNewestFirst bool `json:"sort_newest_first"`
|
||||||
"sort_newest_first": true,
|
ThemeName string `json:"theme_name"`
|
||||||
"theme_name": "light",
|
ThemeFont string `json:"theme_font"`
|
||||||
"theme_font": "",
|
ThemeSize int `json:"theme_size"`
|
||||||
"theme_size": 1,
|
RefreshRate int64 `json:"refresh_rate"`
|
||||||
"refresh_rate": 0,
|
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 (s *Storage) GetSettingsValue(key string) interface{} {
|
func settingsDefaults() Settings {
|
||||||
row := s.db.QueryRow(`select val from settings where key=:key`, sql.Named("key", key))
|
return Settings{
|
||||||
if row == nil {
|
Filter: "",
|
||||||
return settingsDefaults()[key]
|
Feed: "",
|
||||||
|
FeedListWidth: 300,
|
||||||
|
ItemListWidth: 300,
|
||||||
|
SortNewestFirst: true,
|
||||||
|
ThemeName: "light",
|
||||||
|
ThemeFont: "",
|
||||||
|
ThemeSize: 1,
|
||||||
|
RefreshRate: 0,
|
||||||
|
Language: "en",
|
||||||
}
|
}
|
||||||
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 {
|
func (s *Storage) GetSettings() Settings {
|
||||||
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()
|
result := settingsDefaults()
|
||||||
rows, err := s.db.Query(`select key, val from settings;`)
|
rows, err := s.db.Query(`select key, val from settings;`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var key string
|
var key string
|
||||||
var val []byte
|
var val []byte
|
||||||
var valDecoded interface{}
|
|
||||||
|
|
||||||
rows.Scan(&key, &val)
|
rows.Scan(&key, &val)
|
||||||
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
|
||||||
log.Print(err)
|
switch key {
|
||||||
continue
|
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)
|
||||||
}
|
}
|
||||||
result[key] = valDecoded
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) UpdateSettings(kv map[string]interface{}) bool {
|
type UpdateSettingsParams struct {
|
||||||
defaults := settingsDefaults()
|
Filter *string `json:"filter"`
|
||||||
for key, val := range kv {
|
Feed *string `json:"feed"`
|
||||||
if defaults[key] == nil {
|
FeedListWidth *int `json:"feed_list_width"`
|
||||||
continue
|
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 *Storage) UpdateSettings(params 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)
|
valEncoded, err := json.Marshal(val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
return err
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
_, err = s.db.Exec(`
|
_, err = tx.Exec(`
|
||||||
insert into settings (key, val) values (:key, :val)
|
insert into settings (key, val) values (:key, :val)
|
||||||
on conflict (key) do update set val=:val`,
|
on conflict (key) do update set val=:val`,
|
||||||
sql.Named("key", key),
|
sql.Named("key", key),
|
||||||
sql.Named("val", valEncoded),
|
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 {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
150
src/storage/settings_test.go
Normal file
150
src/storage/settings_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsDefaults(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
settings := s.GetSettings()
|
||||||
|
defaults := settingsDefaults()
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(settings, defaults) {
|
||||||
|
t.Errorf("expected defaults %+v, got %+v", defaults, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettings(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
params := 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) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
s.UpdateSettings(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) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
settingsType := reflect.TypeOf(Settings{})
|
||||||
|
paramsType := reflect.TypeOf(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().(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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,31 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
"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 Storage struct {
|
type Storage struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Nullable[T any] struct {
|
||||||
|
Set bool
|
||||||
|
Value *T
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetNullable[T any](v *T) Nullable[T] {
|
||||||
|
return Nullable[T]{Set: true, Value: v}
|
||||||
|
}
|
||||||
|
|
||||||
func New(path string) (*Storage, error) {
|
func New(path string) (*Storage, error) {
|
||||||
if pos := strings.IndexRune(path, '?'); pos == -1 {
|
if pos := strings.IndexRune(path, '?'); pos == -1 {
|
||||||
params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"
|
params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"
|
||||||
@@ -19,7 +37,7 @@ func New(path string) (*Storage, error) {
|
|||||||
path = path + "?" + params
|
path = path + "?" + params
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", path)
|
db, err := sql.Open("sqlite3_yarr", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -29,3 +47,7 @@ func New(path string) (*Storage, error) {
|
|||||||
}
|
}
|
||||||
return &Storage{db: db}, nil
|
return &Storage{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import (
|
|||||||
|
|
||||||
func testDB() *Storage {
|
func testDB() *Storage {
|
||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
db, _ := New(":memory:")
|
db, err := New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/content/scraper"
|
"github.com/nkanaev/yarr/src/content/scraper"
|
||||||
"github.com/nkanaev/yarr/src/parser"
|
"github.com/nkanaev/yarr/src/parser"
|
||||||
@@ -141,7 +142,6 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
|
|||||||
func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
||||||
result := make([]storage.Item, len(items))
|
result := make([]storage.Item, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
item := item
|
|
||||||
mediaLinks := make(storage.MediaLinks, 0)
|
mediaLinks := make(storage.MediaLinks, 0)
|
||||||
for _, link := range item.MediaLinks {
|
for _, link := range item.MediaLinks {
|
||||||
mediaLinks = append(mediaLinks, storage.MediaLink(link))
|
mediaLinks = append(mediaLinks, storage.MediaLink(link))
|
||||||
@@ -163,9 +163,9 @@ func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
|||||||
func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
||||||
lmod := ""
|
lmod := ""
|
||||||
etag := ""
|
etag := ""
|
||||||
if state := db.GetHTTPState(f.Id); state != nil {
|
if state, _ := db.GetFeedState(f.Id); state != nil {
|
||||||
lmod = state.LastModified
|
lmod = state.HTTPLastModified
|
||||||
etag = state.Etag
|
etag = state.HTTPEtag
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := client.getConditional(f.FeedLink, lmod, etag)
|
res, err := client.getConditional(f.FeedLink, lmod, etag)
|
||||||
@@ -191,8 +191,13 @@ func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
|||||||
|
|
||||||
lmod = res.Header.Get("Last-Modified")
|
lmod = res.Header.Get("Last-Modified")
|
||||||
etag = res.Header.Get("Etag")
|
etag = res.Header.Get("Etag")
|
||||||
|
now := time.Now().UTC()
|
||||||
if lmod != "" || etag != "" {
|
if lmod != "" || etag != "" {
|
||||||
db.SetHTTPState(f.Id, lmod, etag)
|
db.UpdateFeedState(f.Id, storage.UpdateFeedStateParams{
|
||||||
|
HTTPLastModified: &lmod,
|
||||||
|
HTTPEtag: &etag,
|
||||||
|
LastRefreshed: &now,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return ConvertItems(feed.Items, f), nil
|
return ConvertItems(feed.Items, f), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,21 +39,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 storage.Feed) {
|
||||||
icon, err := findFavicon(feed.Link, feed.FeedLink)
|
icon, err := findFavicon(feed.Link, feed.FeedLink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to find favicon for %s (%s): %s", feed.FeedLink, feed.Link, err)
|
log.Printf("Failed to find favicon for %s (%s): %s", feed.FeedLink, feed.Link, err)
|
||||||
}
|
}
|
||||||
if icon != nil {
|
if icon != nil {
|
||||||
w.db.UpdateFeedIcon(feed.Id, icon)
|
w.db.UpdateFeed(feed.Id, storage.UpdateFeedParams{Icon: storage.SetNullable(icon)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,26 +100,24 @@ func (w *Worker) RefreshFeeds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) refresher(feeds []storage.Feed) {
|
func (w *Worker) refresher(feeds []storage.Feed) {
|
||||||
w.db.ResetFeedErrors()
|
// w.db.ResetFeedErrors()
|
||||||
|
|
||||||
srcqueue := make(chan storage.Feed, len(feeds))
|
srcqueue := make(chan storage.Feed, len(feeds))
|
||||||
dstqueue := make(chan []storage.Item)
|
dstqueue := make(chan []storage.Item)
|
||||||
|
|
||||||
for i := 0; i < NUM_WORKERS; i++ {
|
for range NUM_WORKERS {
|
||||||
go w.worker(srcqueue, dstqueue)
|
go w.worker(srcqueue, dstqueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, feed := range feeds {
|
for _, feed := range feeds {
|
||||||
srcqueue <- feed
|
srcqueue <- feed
|
||||||
}
|
}
|
||||||
for i := 0; i < len(feeds); i++ {
|
for range feeds {
|
||||||
items := <-dstqueue
|
items := <-dstqueue
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
w.db.CreateItems(items)
|
w.db.CreateItems(items)
|
||||||
w.db.SetFeedSize(items[0].FeedId, len(items))
|
|
||||||
}
|
}
|
||||||
atomic.AddInt32(w.pending, -1)
|
atomic.AddInt32(w.pending, -1)
|
||||||
w.db.SyncSearch()
|
|
||||||
}
|
}
|
||||||
close(srcqueue)
|
close(srcqueue)
|
||||||
close(dstqueue)
|
close(dstqueue)
|
||||||
@@ -137,9 +127,16 @@ func (w *Worker) refresher(feeds []storage.Feed) {
|
|||||||
|
|
||||||
func (w *Worker) worker(srcqueue <-chan storage.Feed, dstqueue chan<- []storage.Item) {
|
func (w *Worker) worker(srcqueue <-chan storage.Feed, dstqueue chan<- []storage.Item) {
|
||||||
for feed := range srcqueue {
|
for feed := range srcqueue {
|
||||||
|
empty := ""
|
||||||
|
w.db.UpdateFeedState(feed.Id, storage.UpdateFeedStateParams{LastError: &empty})
|
||||||
|
|
||||||
items, err := listItems(feed, w.db)
|
items, err := listItems(feed, w.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.db.SetFeedError(feed.Id, err)
|
errMsg := err.Error()
|
||||||
|
w.db.UpdateFeedState(feed.Id, storage.UpdateFeedStateParams{LastError: &errMsg})
|
||||||
|
}
|
||||||
|
if len(items) > 0 && !feed.HasIcon {
|
||||||
|
w.FindFeedFavicon(feed)
|
||||||
}
|
}
|
||||||
dstqueue <- items
|
dstqueue <- items
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user