mirror of
https://github.com/nkanaev/yarr.git
synced 2025-12-23 11:44:04 +00:00
Compare commits
10 Commits
e49f9910d8
...
gh-actions
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
348693fa95 | ||
|
|
b40c6fc9e4 | ||
|
|
73fd637b23 | ||
|
|
b8afa82a81 | ||
|
|
097a2da5cb | ||
|
|
e6d32946c1 | ||
|
|
fe4eaa4b8d | ||
|
|
48a671b285 | ||
|
|
011c9c7668 | ||
|
|
f06fc1f750 |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -1,6 +1,14 @@
|
||||
name: build
|
||||
name: Build
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: [Test]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
|
||||
19
.github/workflows/test.yml
vendored
Normal file
19
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Test
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '^1.18'
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
@@ -3,6 +3,7 @@
|
||||
- (new) Fever API support (thanks to @icefed)
|
||||
- (new) editable feed link (thanks to @adaszko)
|
||||
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
|
||||
- (new) support multiple media links
|
||||
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
|
||||
- (fix) relative article links (thanks to @adazsko for the report)
|
||||
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/nkanaev/yarr
|
||||
|
||||
go 1.17
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.7
|
||||
|
||||
13
makefile
13
makefile
@@ -1,6 +1,7 @@
|
||||
VERSION=2.4
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
GO_TAGS = sqlite_foreign_keys sqlite_json
|
||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
|
||||
export GOARCH ?= amd64
|
||||
@@ -8,26 +9,26 @@ export CGO_ENABLED = 1
|
||||
|
||||
build_default:
|
||||
mkdir -p _output
|
||||
go build -tags "sqlite_foreign_keys" -ldflags="$(GO_LDFLAGS)" -o _output/yarr ./cmd/yarr
|
||||
go build -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)" -o _output/yarr ./cmd/yarr
|
||||
|
||||
build_macos:
|
||||
mkdir -p _output/macos
|
||||
GOOS=darwin go build -tags "sqlite_foreign_keys macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr ./cmd/yarr
|
||||
GOOS=darwin go build -tags "$(GO_TAGS) macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr ./cmd/yarr
|
||||
cp src/platform/icon.png _output/macos/icon.png
|
||||
go run ./cmd/package_macos -outdir _output/macos -version "$(VERSION)"
|
||||
|
||||
build_linux:
|
||||
mkdir -p _output/linux
|
||||
GOOS=linux go build -tags "sqlite_foreign_keys linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr ./cmd/yarr
|
||||
GOOS=linux go build -tags "$(GO_TAGS) linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr ./cmd/yarr
|
||||
|
||||
build_windows:
|
||||
mkdir -p _output/windows
|
||||
go run ./cmd/generate_versioninfo -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
|
||||
GOOS=windows go build -tags "sqlite_foreign_keys windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe ./cmd/yarr
|
||||
GOOS=windows go build -tags "$(GO_TAGS) windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe ./cmd/yarr
|
||||
|
||||
serve:
|
||||
go run -tags "sqlite_foreign_keys" ./cmd/yarr -db local.db
|
||||
go run -tags "$(GO_TAGS)" ./cmd/yarr -db local.db
|
||||
|
||||
test:
|
||||
go test -tags "sqlite_foreign_keys" ./...
|
||||
go test -tags "$(GO_TAGS)" ./...
|
||||
|
||||
@@ -25,18 +25,21 @@
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
:aria-pressed="filterSelected == 'unread'"
|
||||
title="Unread"
|
||||
@click="filterSelected = 'unread'">
|
||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == 'starred'}"
|
||||
:aria-pressed="filterSelected == 'starred'"
|
||||
title="Starred"
|
||||
@click="filterSelected = 'starred'">
|
||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == ''}"
|
||||
:aria-pressed="filterSelected == ''"
|
||||
title="All"
|
||||
@click="filterSelected = ''">
|
||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||
@@ -59,10 +62,12 @@
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Theme</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Theme</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||
:class="'theme-'+t"
|
||||
:aria-label="t"
|
||||
:aria-pressed="theme.name == t"
|
||||
@click.stop="theme.name = t"
|
||||
v-for="t in ['light', 'sepia', 'night']">
|
||||
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||
@@ -71,25 +76,25 @@
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Auto Refresh</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="!refreshRate" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 10" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 30" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 60" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 120" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 240" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Show first</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Show first</header>
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item px-0" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
|
||||
<button class="dropdown-item px-0" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header">Subscriptions</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
|
||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||
<input type="file"
|
||||
id="opml-import"
|
||||
@@ -206,7 +211,7 @@
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<header class="dropdown-header">{{ current.feed.title }}</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
||||
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||
Website
|
||||
@@ -225,16 +230,7 @@
|
||||
Change Link
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Expire Unreads</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: feedExpire == 1440}" @click.stop="feedExpireUnreads(current.feed, 1440)">1d</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: feedExpire == 10080}" @click.stop="feedExpireUnreads(current.feed, 10080)">1w</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: feedExpire == 43200}" @click.stop="feedExpireUnreads(current.feed, 43200)">1m</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header">Move to...</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Move to...</header>
|
||||
<button class="dropdown-item"
|
||||
v-if="folder.id != current.feed.folder_id"
|
||||
v-for="folder in folders"
|
||||
@@ -264,7 +260,7 @@
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<header class="dropdown-header">{{ current.folder.title }}</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
Rename
|
||||
@@ -366,8 +362,14 @@
|
||||
</div>
|
||||
<hr>
|
||||
<div v-if="!itemSelectedReadability">
|
||||
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
|
||||
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
|
||||
<div v-if="contentImages.length">
|
||||
<figure v-for="media in contentImages">
|
||||
<img :src="media.url" loading="lazy">
|
||||
<figcaption v-if="media.description" v-html="media.description"></figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
|
||||
<video class="w-100" controls v-for="media in contentVideos" :src="media.url"></video>
|
||||
</div>
|
||||
<div v-html="itemSelectedContent"></div>
|
||||
</div>
|
||||
|
||||
@@ -52,9 +52,6 @@
|
||||
list_errors: function() {
|
||||
return api('get', './api/feeds/errors').then(json)
|
||||
},
|
||||
get_expire_minutes: function(id) {
|
||||
return api('get', './api/feeds/' + id + '/expire').then(json)
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
list: function() {
|
||||
|
||||
@@ -250,8 +250,6 @@ var vm = new Vue({
|
||||
'size': s.theme_size,
|
||||
},
|
||||
'refreshRate': s.refresh_rate,
|
||||
'expireUnreads': s.expiration_rate,
|
||||
'feedExpire': 0,
|
||||
'authenticated': app.authenticated,
|
||||
'feed_errors': {},
|
||||
}
|
||||
@@ -300,6 +298,18 @@ var vm = new Vue({
|
||||
|
||||
return this.itemSelectedDetails.content || ''
|
||||
},
|
||||
contentImages: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'image')
|
||||
},
|
||||
contentAudios: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'audio')
|
||||
},
|
||||
contentVideos: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video')
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'theme': {
|
||||
@@ -335,18 +345,8 @@ var vm = new Vue({
|
||||
},
|
||||
'feedSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.itemSelected = null
|
||||
|
||||
var parts = newVal.split(':', 2)
|
||||
if (parts[0] == 'feed') {
|
||||
var feed_id = parts[1]
|
||||
api.feeds.get_expire_minutes(feed_id).then(function(resp) {
|
||||
vm.feedExpire = resp.feedExpire
|
||||
})
|
||||
}
|
||||
|
||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||
},
|
||||
'itemSelected': function(newVal, oldVal) {
|
||||
@@ -508,14 +508,6 @@ var vm = new Vue({
|
||||
}
|
||||
return new Date(datestr).toLocaleDateString(undefined, options)
|
||||
},
|
||||
'feedExpireUnreads': function(feed, newVal) {
|
||||
if (vm.feedExpire == newVal) {
|
||||
newVal = 0
|
||||
}
|
||||
api.feeds.update(feed.id, {expire_minutes: newVal}).then(function() {
|
||||
vm.feedExpire = newVal
|
||||
})
|
||||
},
|
||||
moveFeed: function(feed, folder) {
|
||||
var folder_id = folder ? folder.id : null
|
||||
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {
|
||||
|
||||
@@ -43,9 +43,9 @@ func FindFeeds(body string, base string) map[string]string {
|
||||
channelID, found := strings.CutPrefix(l.Query().Get("channel_id"), "UC")
|
||||
if found {
|
||||
const url string = "https://www.youtube.com/feeds/videos.xml?playlist_id="
|
||||
candidates[url + "UULF" + channelID] = name + " - Videos"
|
||||
candidates[url + "UULV" + channelID] = name + " - Live Streams"
|
||||
candidates[url + "UUSH" + channelID] = name + " - Short videos"
|
||||
candidates[url+"UULF"+channelID] = name + " - Videos"
|
||||
candidates[url+"UULV"+channelID] = name + " - Live Streams"
|
||||
candidates[url+"UUSH"+channelID] = name + " - Short videos"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package expirer
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
)
|
||||
|
||||
type Expirer struct {
|
||||
db *storage.Storage
|
||||
stop chan bool
|
||||
intervalMinutes uint64
|
||||
}
|
||||
|
||||
func NewExpirer(db *storage.Storage) *Expirer {
|
||||
return &Expirer{
|
||||
db: db,
|
||||
stop: make(chan bool),
|
||||
intervalMinutes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func expire(db *storage.Storage, rateMinutes uint64, stop chan bool) {
|
||||
tick := time.NewTicker(time.Minute * time.Duration(rateMinutes) / 2)
|
||||
db.ExpireUnreads(rateMinutes)
|
||||
log.Printf("expirer %dm: starting", rateMinutes)
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
log.Printf("expirer %dm: firing", rateMinutes)
|
||||
db.ExpireUnreads(rateMinutes)
|
||||
case <-stop:
|
||||
log.Printf("expirer %dm: stopping", rateMinutes)
|
||||
tick.Stop()
|
||||
stop <- true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Expirer) getCheckInterval(globalExpirationPeriod uint64) uint64 {
|
||||
minFeedExpirationPeriod, err := e.db.GetMinExpirationPeriod()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
checkInterval := globalExpirationPeriod
|
||||
if *minFeedExpirationPeriod != 0 && *minFeedExpirationPeriod < checkInterval {
|
||||
checkInterval = *minFeedExpirationPeriod
|
||||
}
|
||||
return checkInterval
|
||||
}
|
||||
|
||||
func (e *Expirer) StartUnreadsExpirer(globalExpirationPeriod uint64) {
|
||||
checkInterval := e.getCheckInterval(globalExpirationPeriod)
|
||||
if checkInterval > 0 {
|
||||
e.intervalMinutes = uint64(checkInterval)
|
||||
go expire(e.db, e.intervalMinutes, e.stop)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Expirer) SetExpirationRate(globalExpirationPeriod uint64) {
|
||||
checkInterval := e.getCheckInterval(globalExpirationPeriod)
|
||||
if checkInterval == e.intervalMinutes {
|
||||
return
|
||||
}
|
||||
e.stop <- true
|
||||
<-e.stop
|
||||
e.intervalMinutes = globalExpirationPeriod
|
||||
if checkInterval == 0 {
|
||||
return
|
||||
}
|
||||
go expire(e.db, e.intervalMinutes, e.stop)
|
||||
}
|
||||
@@ -89,6 +89,8 @@ func ParseAtom(r io.Reader) (*Feed, error) {
|
||||
guidFromID = srcitem.ID + "::" + srcitem.Updated
|
||||
}
|
||||
|
||||
mediaLinks := srcitem.mediaLinks()
|
||||
|
||||
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""), linkFromID)
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
|
||||
@@ -96,8 +98,7 @@ func ParseAtom(r io.Reader) (*Feed, error) {
|
||||
URL: link,
|
||||
Title: srcitem.Title.Text(),
|
||||
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
|
||||
ImageURL: srcitem.firstMediaThumbnail(),
|
||||
AudioURL: "",
|
||||
MediaLinks: mediaLinks,
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
|
||||
@@ -45,8 +45,6 @@ func TestAtom(t *testing.T) {
|
||||
URL: "http://example.org/2003/12/13/atom03.html",
|
||||
Title: "Atom-Powered Robots Run Amok",
|
||||
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
|
||||
ImageURL: "",
|
||||
AudioURL: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -141,9 +139,15 @@ func TestAtomImageLink(t *testing.T) {
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].ImageURL
|
||||
want := `https://example.com/image.png?width=100&height=100`
|
||||
if want != have {
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got: %#v", feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: `https://example.com/image.png?width=100&height=100`,
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("item.image_url doesn't match\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
}
|
||||
@@ -165,8 +169,8 @@ func TestAtomImageLinkDuplicated(t *testing.T) {
|
||||
if want != have {
|
||||
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
if feed.Items[0].ImageURL != "" {
|
||||
t.Fatal("item.image_url must be unset if present in the content")
|
||||
if len(feed.Items[0].MediaLinks) != 0 {
|
||||
t.Fatal("item media link must be excluded if present in the content")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,11 +134,14 @@ func (feed *Feed) cleanup() {
|
||||
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
|
||||
feed.Items[i].Content = strings.TrimSpace(item.Content)
|
||||
|
||||
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) {
|
||||
feed.Items[i].ImageURL = ""
|
||||
if len(feed.Items[i].MediaLinks) > 0 {
|
||||
mediaLinks := make([]MediaLink, 0)
|
||||
for _, link := range item.MediaLinks {
|
||||
if !strings.Contains(item.Content, link.URL) {
|
||||
mediaLinks = append(mediaLinks, link)
|
||||
}
|
||||
if item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) {
|
||||
feed.Items[i].AudioURL = ""
|
||||
}
|
||||
feed.Items[i].MediaLinks = mediaLinks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type media struct {
|
||||
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||
@@ -8,12 +12,17 @@ type media struct {
|
||||
}
|
||||
|
||||
type mediaGroup struct {
|
||||
MediaContent []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||
}
|
||||
|
||||
type mediaContent struct {
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaType string `xml:"type,attr"`
|
||||
MediaMedium string `xml:"medium,attr"`
|
||||
MediaURL string `xml:"url,attr"`
|
||||
MediaDescription mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||
}
|
||||
|
||||
type mediaThumbnail struct {
|
||||
@@ -22,7 +31,7 @@ type mediaThumbnail struct {
|
||||
|
||||
type mediaDescription struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Description string `xml:",chardata"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func (m *media) firstMediaThumbnail() string {
|
||||
@@ -44,12 +53,59 @@ func (m *media) firstMediaThumbnail() string {
|
||||
|
||||
func (m *media) firstMediaDescription() string {
|
||||
for _, d := range m.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
return plain2html(d.Text)
|
||||
}
|
||||
for _, g := range m.MediaGroups {
|
||||
for _, d := range g.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
return plain2html(d.Text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *media) mediaLinks() []MediaLink {
|
||||
links := make([]MediaLink, 0)
|
||||
for _, thumbnail := range m.MediaThumbnails {
|
||||
links = append(links, MediaLink{URL: thumbnail.URL, Type: "image"})
|
||||
}
|
||||
for _, group := range m.MediaGroups {
|
||||
for _, thumbnail := range group.MediaThumbnails {
|
||||
links = append(links, MediaLink{
|
||||
URL: thumbnail.URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, content := range m.MediaContents {
|
||||
if content.MediaURL != "" {
|
||||
url := content.MediaURL
|
||||
description := content.MediaDescription.Text
|
||||
if strings.HasPrefix(content.MediaType, "image/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "image", Description: description})
|
||||
} else if strings.HasPrefix(content.MediaType, "audio/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "audio", Description: description})
|
||||
} else if strings.HasPrefix(content.MediaType, "video/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "video", Description: description})
|
||||
} else if content.MediaMedium == "image" || content.MediaMedium == "audio" || content.MediaMedium == "video" {
|
||||
links = append(links, MediaLink{URL: url, Type: content.MediaMedium, Description: description})
|
||||
} else {
|
||||
if len(content.MediaThumbnails) > 0 {
|
||||
links = append(links, MediaLink{
|
||||
URL: content.MediaThumbnails[0].URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, thumbnail := range content.MediaThumbnails {
|
||||
links = append(links, MediaLink{
|
||||
URL: thumbnail.URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ type Item struct {
|
||||
Title string
|
||||
|
||||
Content string
|
||||
ImageURL string
|
||||
AudioURL string
|
||||
MediaLinks []MediaLink
|
||||
}
|
||||
|
||||
type MediaLink struct {
|
||||
URL string
|
||||
Type string
|
||||
Description string
|
||||
}
|
||||
|
||||
@@ -74,14 +74,14 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
SiteURL: srcfeed.Link,
|
||||
}
|
||||
for _, srcitem := range srcfeed.Items {
|
||||
podcastURL := ""
|
||||
mediaLinks := srcitem.mediaLinks()
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if strings.HasPrefix(e.Type, "audio/") {
|
||||
podcastURL = e.URL
|
||||
|
||||
podcastURL := e.URL
|
||||
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
podcastURL = srcitem.OrigEnclosureLink
|
||||
}
|
||||
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,8 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
|
||||
AudioURL: podcastURL,
|
||||
ImageURL: srcitem.firstMediaThumbnail(),
|
||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
|
||||
MediaLinks: mediaLinks,
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
|
||||
@@ -75,9 +75,15 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].ImageURL
|
||||
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
|
||||
if have != want {
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "https://i.vimeocdn.com/video/1092705247_960.jpg",
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
@@ -127,9 +133,15 @@ func TestRSSPodcast(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].AudioURL
|
||||
want := "http://example.com/audio.ext"
|
||||
if want != have {
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatal("Invalid media links")
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/audio.ext",
|
||||
Type: "audio",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
@@ -147,9 +159,15 @@ func TestRSSOpusPodcast(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].AudioURL
|
||||
want := "http://example.com/audio.ext"
|
||||
if want != have {
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatal("Invalid media links")
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/audio.ext",
|
||||
Type: "audio",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
@@ -176,8 +194,9 @@ func TestRSSPodcastDuplicated(t *testing.T) {
|
||||
if want != have {
|
||||
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
if feed.Items[0].AudioURL != "" {
|
||||
t.Fatal("item.audio_url must be unset if present in the content")
|
||||
|
||||
if len(feed.Items[0].MediaLinks) != 0 {
|
||||
t.Fatal("item media must be excluded if present in the content")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +242,47 @@ func TestRSSIsPermalink(t *testing.T) {
|
||||
},
|
||||
}
|
||||
for i := 0; i < len(want); i++ {
|
||||
if want[i] != have[i] {
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSMultipleMedia(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<item>
|
||||
<guid isPermaLink="true">http://example.com/posts/1</guid>
|
||||
<media:content url="https://example.com/path/to/image1.png" type="image/png" fileSize="1000" medium="image">
|
||||
<media:description type="plain">description 1</media:description>
|
||||
</media:content>
|
||||
<media:content url="https://example.com/path/to/image2.png" type="image/png" fileSize="2000" medium="image">
|
||||
<media:description type="plain">description 2</media:description>
|
||||
</media:content>
|
||||
<media:content url="https://example.com/path/to/video1.mp4" type="video/mp4" fileSize="2000" medium="image">
|
||||
<media:description type="plain">video description</media:description>
|
||||
</media:content>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items
|
||||
want := []Item{
|
||||
{
|
||||
GUID: "http://example.com/posts/1",
|
||||
URL: "http://example.com/posts/1",
|
||||
MediaLinks: []MediaLink{
|
||||
{URL:"https://example.com/path/to/image1.png", Type:"image", Description:"description 1"},
|
||||
{URL:"https://example.com/path/to/image2.png", Type:"image", Description:"description 2"},
|
||||
{URL:"https://example.com/path/to/video1.mp4", Type:"video", Description:"video description"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid rss")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
func Start(s *server.Server) {
|
||||
systrayOnReady := func() {
|
||||
systray.SetIcon(Icon)
|
||||
systray.SetTooltip("yarr")
|
||||
|
||||
menuOpen := systray.AddMenuItem("Open", "")
|
||||
systray.AddSeparator()
|
||||
|
||||
@@ -51,7 +51,6 @@ func (s *Server) handler() http.Handler {
|
||||
r.For("/api/feeds/errors", s.handleFeedErrors)
|
||||
r.For("/api/feeds/:id/icon", s.handleFeedIcon)
|
||||
r.For("/api/feeds/:id", s.handleFeed)
|
||||
r.For("/api/feeds/:id/expire", s.handleFeedExpire)
|
||||
r.For("/api/items", s.handleItemList)
|
||||
r.For("/api/items/:id", s.handleItem)
|
||||
r.For("/api/settings", s.handleSettings)
|
||||
@@ -88,7 +87,7 @@ func (s *Server) handleManifest(c *router.Context) {
|
||||
"short_name": "yarr",
|
||||
"description": "yet another rss reader",
|
||||
"display": "standalone",
|
||||
"start_url": s.BasePath,
|
||||
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
||||
"icons": []map[string]interface{}{
|
||||
{
|
||||
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
||||
@@ -264,54 +263,13 @@ func (s *Server) handleFeedList(c *router.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleFeedExpire(c *router.Context) {
|
||||
feedId, err := c.VarInt64("id")
|
||||
if err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Req.Method != "GET" {
|
||||
c.Out.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
feed := s.db.GetFeed(feedId)
|
||||
if feed == nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expire_minutes, err := s.db.GetFeedExpirationRate(feedId)
|
||||
if err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"feedExpire": expire_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleFeed(c *router.Context) {
|
||||
id, err := c.VarInt64("id")
|
||||
if err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Req.Method == "GET" {
|
||||
feedId, err := c.QueryInt64("feed_id")
|
||||
if err == nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
feed := s.db.GetFeed(feedId)
|
||||
if feed == nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expire_minutes, err := s.db.GetFeedExpirationRate(feedId)
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"expire_minutes": expire_minutes,
|
||||
})
|
||||
} else if c.Req.Method == "PUT" {
|
||||
if c.Req.Method == "PUT" {
|
||||
feed := s.db.GetFeed(id)
|
||||
if feed == nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
@@ -341,15 +299,6 @@ func (s *Server) handleFeed(c *router.Context) {
|
||||
s.db.UpdateFeedLink(id, link.(string))
|
||||
}
|
||||
}
|
||||
if expire_minutes, ok := body["expire_minutes"]; ok {
|
||||
if reflect.TypeOf(expire_minutes).Kind() == reflect.Float64 {
|
||||
minutes := int64(expire_minutes.(float64))
|
||||
err := s.db.UpdateFeedExpirationRate(id, minutes)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else if c.Req.Method == "DELETE" {
|
||||
s.db.DeleteFeed(id)
|
||||
@@ -380,6 +329,9 @@ func (s *Server) handleItem(c *router.Context) {
|
||||
}
|
||||
|
||||
item.Content = sanitizer.Sanitize(item.Link, item.Content)
|
||||
for i, link := range item.MediaLinks {
|
||||
item.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
} else if c.Req.Method == "PUT" {
|
||||
@@ -468,10 +420,6 @@ func (s *Server) handleSettings(c *router.Context) {
|
||||
if _, ok := settings["refresh_rate"]; ok {
|
||||
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate"))
|
||||
}
|
||||
if _, ok := settings["expiration_rate"]; ok {
|
||||
expirationRate := s.db.GetSettingsValueInt64("expiration_rate")
|
||||
s.expirer.SetExpirationRate(uint64(expirationRate))
|
||||
}
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/nkanaev/yarr/src/expirer"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/worker"
|
||||
)
|
||||
@@ -14,7 +13,6 @@ type Server struct {
|
||||
Addr string
|
||||
db *storage.Storage
|
||||
worker *worker.Worker
|
||||
expirer *expirer.Expirer
|
||||
cache map[string]interface{}
|
||||
cache_mutex *sync.Mutex
|
||||
|
||||
@@ -33,7 +31,6 @@ func NewServer(db *storage.Storage, addr string) *Server {
|
||||
db: db,
|
||||
Addr: addr,
|
||||
worker: worker.NewWorker(db),
|
||||
expirer: expirer.NewExpirer(db),
|
||||
cache: make(map[string]interface{}),
|
||||
cache_mutex: &sync.Mutex{},
|
||||
}
|
||||
@@ -56,9 +53,6 @@ func (s *Server) Start() {
|
||||
s.worker.RefreshFeeds()
|
||||
}
|
||||
|
||||
globalExpirationPeriod := s.db.GetSettingsValueInt64("expiration_rate")
|
||||
s.expirer.StartUnreadsExpirer(uint64(globalExpirationPeriod))
|
||||
|
||||
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()}
|
||||
|
||||
var err error
|
||||
|
||||
@@ -76,31 +76,6 @@ func (s *Storage) UpdateFeedLink(feedId int64, newLink string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetFeedExpirationRate(feedId int64) (*uint64, error) {
|
||||
var result uint64
|
||||
row := s.db.QueryRow(`select expire_minutes from feeds where id = ?`, feedId)
|
||||
err := row.Scan(&result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateFeedExpirationRate(feedId int64, expireMinutes int64) error {
|
||||
_, err := s.db.Exec(`update feeds set expire_minutes = ? where id = ?`, expireMinutes, feedId)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) GetMinExpirationPeriod() (*uint64, error) {
|
||||
var result uint64
|
||||
row := s.db.QueryRow(`select min(expire_minutes) from feeds`)
|
||||
err := row.Scan(&result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
|
||||
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
|
||||
return err == nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -44,6 +45,25 @@ func (s *ItemStatus) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type MediaLink struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type MediaLinks []MediaLink
|
||||
|
||||
func (m *MediaLinks) Scan(src any) error {
|
||||
if data, ok := src.([]byte); ok {
|
||||
return json.Unmarshal(data, m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m MediaLinks) Value() (driver.Value, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Id int64 `json:"id"`
|
||||
GUID string `json:"guid"`
|
||||
@@ -53,8 +73,7 @@ type Item struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Date time.Time `json:"date"`
|
||||
Status ItemStatus `json:"status"`
|
||||
ImageURL *string `json:"image"`
|
||||
AudioURL *string `json:"podcast_url"`
|
||||
MediaLinks MediaLinks `json:"media_links"`
|
||||
}
|
||||
|
||||
type ItemFilter struct {
|
||||
@@ -110,13 +129,17 @@ func (s *Storage) CreateItems(items []Item) bool {
|
||||
_, err = tx.Exec(`
|
||||
insert into items (
|
||||
guid, feed_id, title, link, date,
|
||||
content, image, podcast_url,
|
||||
content, media_links,
|
||||
date_arrived, status
|
||||
)
|
||||
values (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?), ?, ?, ?, ?, ?)
|
||||
values (
|
||||
?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?),
|
||||
?, ?,
|
||||
?, ?
|
||||
)
|
||||
on conflict (feed_id, guid) do nothing`,
|
||||
item.GUID, item.FeedId, item.Title, item.Link, item.Date,
|
||||
item.Content, item.ImageURL, item.AudioURL,
|
||||
item.Content, item.MediaLinks,
|
||||
now, UNREAD,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -231,7 +254,7 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, with
|
||||
order = "i.id desc"
|
||||
}
|
||||
|
||||
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.image, i.podcast_url"
|
||||
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
|
||||
if withContent {
|
||||
selectCols += ", i.content"
|
||||
} else {
|
||||
@@ -254,7 +277,7 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, with
|
||||
err = rows.Scan(
|
||||
&x.Id, &x.GUID, &x.FeedId,
|
||||
&x.Title, &x.Link, &x.Date,
|
||||
&x.Status, &x.ImageURL, &x.AudioURL, &x.Content,
|
||||
&x.Status, &x.MediaLinks, &x.Content,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
@@ -270,12 +293,12 @@ func (s *Storage) GetItem(id int64) *Item {
|
||||
err := s.db.QueryRow(`
|
||||
select
|
||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||
i.date, i.status, i.image, i.podcast_url
|
||||
i.date, i.status, i.media_links
|
||||
from items i
|
||||
where i.id = ?
|
||||
`, id).Scan(
|
||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL,
|
||||
&i.Date, &i.Status, &i.MediaLinks,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
@@ -372,51 +395,6 @@ func (s *Storage) SyncSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) ExpireUnreads(globalExpireMinutes uint64) {
|
||||
var numExpiredItems int64
|
||||
|
||||
// Collect expiration rates for each feed -- it's either the global default or a per-feed override
|
||||
rows, err := s.db.Query(`select id, iif(expire_minutes = 0, ?, expire_minutes) from feeds`, globalExpireMinutes)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
periodFromFeedId := make(map[int64]int64, 0)
|
||||
for rows.Next() {
|
||||
var feedId, period int64
|
||||
if period == 0 {
|
||||
continue
|
||||
}
|
||||
rows.Scan(&feedId, &period, nil)
|
||||
periodFromFeedId[feedId] = period
|
||||
}
|
||||
|
||||
for feedId, period := range periodFromFeedId {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE items SET status = ? WHERE feed_id = ? AND status = ? AND (julianday('now') - julianday(date_arrived)) * 3600 >= ?`,
|
||||
feedId,
|
||||
READ,
|
||||
UNREAD,
|
||||
period,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
numExpiredItems += rowsAffected
|
||||
}
|
||||
|
||||
if numExpiredItems > 0 {
|
||||
log.Printf("Expired %d old unread items", numExpiredItems)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
itemsKeepSize = 50
|
||||
itemsKeepDays = 90
|
||||
|
||||
@@ -77,12 +77,12 @@ func getItem(db *Storage, guid string) *Item {
|
||||
err := db.db.QueryRow(`
|
||||
select
|
||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||
i.date, i.status, i.image, i.podcast_url
|
||||
i.date, i.status, i.media_links
|
||||
from items i
|
||||
where i.guid = ?
|
||||
`, guid).Scan(
|
||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL,
|
||||
&i.Date, &i.Status, &i.MediaLinks,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -17,7 +17,7 @@ var migrations = []func(*sql.Tx) error{
|
||||
m07_add_feed_size,
|
||||
m08_normalize_datetime,
|
||||
m09_change_item_index,
|
||||
m10_per_feed_expiration,
|
||||
m10_add_item_medialinks,
|
||||
}
|
||||
|
||||
var maxVersion = int64(len(migrations))
|
||||
@@ -104,8 +104,7 @@ func m01_initial(tx *sql.Tx) error {
|
||||
description text,
|
||||
link text,
|
||||
feed_link text not null,
|
||||
icon blob,
|
||||
expire_minutes integer not null default 0
|
||||
icon blob
|
||||
);
|
||||
|
||||
create index if not exists idx_feed_folder_id on feeds(folder_id);
|
||||
@@ -309,8 +308,27 @@ func m09_change_item_index(tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func m10_per_feed_expiration(tx *sql.Tx) error {
|
||||
sql := `alter table feeds add column expire_minutes integer not null default 0;`
|
||||
func m10_add_item_medialinks(tx *sql.Tx) error {
|
||||
sql := `
|
||||
alter table items add column media_links blob;
|
||||
update items set media_links =
|
||||
iif(
|
||||
coalesce(image, '') != '' and coalesce(podcast_url, '') != '',
|
||||
json_array(json_object('type', 'image', 'url', image), json_object('type', 'audio', 'url', podcast_url)),
|
||||
iif(
|
||||
coalesce(image, '') != '',
|
||||
json_array(json_object('type', 'image', 'url', image)),
|
||||
iif(
|
||||
coalesce(podcast_url, '') != '',
|
||||
json_array(json_object('type', 'audio', 'url', podcast_url)),
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
alter table items drop column image;
|
||||
alter table items drop column podcast_url;
|
||||
`
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ func settingsDefaults() map[string]interface{} {
|
||||
"theme_font": "",
|
||||
"theme_size": 1,
|
||||
"refresh_rate": 0,
|
||||
"expiration_rate": 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build darwin || windows
|
||||
// +build darwin windows
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build never
|
||||
// +build never
|
||||
|
||||
package systray
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package systray
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package systray
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package systray
|
||||
|
||||
@@ -143,13 +143,9 @@ func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
||||
result := make([]storage.Item, len(items))
|
||||
for i, item := range items {
|
||||
item := item
|
||||
var audioURL *string = nil
|
||||
if item.AudioURL != "" {
|
||||
audioURL = &item.AudioURL
|
||||
}
|
||||
var imageURL *string = nil
|
||||
if item.ImageURL != "" {
|
||||
imageURL = &item.ImageURL
|
||||
mediaLinks := make(storage.MediaLinks, 0)
|
||||
for _, link := range item.MediaLinks {
|
||||
mediaLinks = append(mediaLinks, storage.MediaLink(link))
|
||||
}
|
||||
result[i] = storage.Item{
|
||||
GUID: item.GUID,
|
||||
@@ -159,8 +155,7 @@ func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
||||
Content: item.Content,
|
||||
Date: item.Date,
|
||||
Status: storage.UNREAD,
|
||||
ImageURL: imageURL,
|
||||
AudioURL: audioURL,
|
||||
MediaLinks: mediaLinks,
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user