10 Commits

Author SHA1 Message Date
nkanaev
348693fa95 github: set workflow trigger conditions 2025-03-12 22:48:05 +00:00
nkanaev
b40c6fc9e4 github: run build after test 2025-03-12 22:22:40 +00:00
nkanaev
73fd637b23 github: test workflow 2025-03-12 22:15:18 +00:00
Nazar Kanaev
b8afa82a81 support multiple media links 2025-03-11 11:15:09 +00:00
nkanaev
097a2da5cb go fmt 2025-03-04 17:05:41 +00:00
nkanaev
e6d32946c1 add aria-pressed tag for the corresponding UI elements 2025-03-04 16:51:30 +00:00
nkanaev
fe4eaa4b8d fix start_url for manifest.json 2025-03-04 14:41:23 +00:00
nkanaev
48a671b285 add header accessibility tags 2025-03-04 14:12:46 +00:00
nkanaev
011c9c7668 add theme toggle button labels 2025-03-04 14:08:42 +00:00
nkanaev
f06fc1f750 add systray icon tooltip 2025-03-04 14:03:52 +00:00
31 changed files with 349 additions and 365 deletions

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -1,6 +1,6 @@
module github.com/nkanaev/yarr
go 1.17
go 1.18
require (
github.com/mattn/go-sqlite3 v1.14.7

View File

@@ -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)" ./...

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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"
}
}
}

View File

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

View File

@@ -89,15 +89,16 @@ 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),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link,
Title: srcitem.Title.Text(),
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
ImageURL: srcitem.firstMediaThumbnail(),
AudioURL: "",
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link,
Title: srcitem.Title.Text(),
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
MediaLinks: mediaLinks,
})
}
return dstfeed, nil

View File

@@ -40,13 +40,11 @@ func TestAtom(t *testing.T) {
SiteURL: "http://example.org/",
Items: []Item{
{
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
Date: time.Unix(1071340202, 0).UTC(),
URL: "http://example.org/2003/12/13/atom03.html",
Title: "Atom-Powered Robots Run Amok",
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
ImageURL: "",
AudioURL: "",
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
Date: time.Unix(1071340202, 0).UTC(),
URL: "http://example.org/2003/12/13/atom03.html",
Title: "Atom-Powered Robots Run Amok",
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
},
},
}
@@ -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")
}
}

View File

@@ -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 item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) {
feed.Items[i].AudioURL = ""
if len(feed.Items[i].MediaLinks) > 0 {
mediaLinks := make([]MediaLink, 0)
for _, link := range item.MediaLinks {
if !strings.Contains(item.Content, link.URL) {
mediaLinks = append(mediaLinks, link)
}
}
feed.Items[i].MediaLinks = mediaLinks
}
}
}

View File

@@ -1,5 +1,9 @@
package parser
import (
"strings"
)
type media struct {
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
@@ -8,12 +12,17 @@ type media struct {
}
type mediaGroup struct {
MediaContent []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
}
type mediaContent struct {
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaType string `xml:"type,attr"`
MediaMedium string `xml:"medium,attr"`
MediaURL string `xml:"url,attr"`
MediaDescription mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
}
type mediaThumbnail struct {
@@ -21,8 +30,8 @@ type mediaThumbnail struct {
}
type mediaDescription struct {
Type string `xml:"type,attr"`
Description string `xml:",chardata"`
Type string `xml:"type,attr"`
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
}

View File

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

View File

@@ -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
}
}
@@ -92,13 +92,12 @@ func ParseRSS(r io.Reader) (*Feed, error) {
}
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
AudioURL: podcastURL,
ImageURL: srcitem.firstMediaThumbnail(),
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
MediaLinks: mediaLinks,
})
}
return dstfeed, nil

View File

@@ -75,9 +75,15 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
</channel>
</rss>
`))
have := feed.Items[0].ImageURL
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
if have != want {
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "https://i.vimeocdn.com/video/1092705247_960.jpg",
Type: "image",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
@@ -127,9 +133,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")
}
}

View File

@@ -11,6 +11,7 @@ import (
func Start(s *server.Server) {
systrayOnReady := func() {
systray.SetIcon(Icon)
systray.SetTooltip("yarr")
menuOpen := systray.AddMenuItem("Open", "")
systray.AddSeparator()

View File

@@ -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)

View File

@@ -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

View File

@@ -21,7 +21,7 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
title = feedLink
}
row := s.db.QueryRow(`
insert into feeds (title, description, link, feed_link, folder_id)
insert into feeds (title, description, link, feed_link, folder_id)
values (?, ?, ?, ?, ?)
on conflict (feed_link) do update set folder_id = ?
returning id`,
@@ -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

View File

@@ -1,6 +1,7 @@
package storage
import (
"database/sql/driver"
"encoding/json"
"fmt"
"log"
@@ -44,17 +45,35 @@ 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"`
FeedId int64 `json:"feed_id"`
Title string `json:"title"`
Link string `json:"link"`
Content string `json:"content,omitempty"`
Date time.Time `json:"date"`
Status ItemStatus `json:"status"`
ImageURL *string `json:"image"`
AudioURL *string `json:"podcast_url"`
Id int64 `json:"id"`
GUID string `json:"guid"`
FeedId int64 `json:"feed_id"`
Title string `json:"title"`
Link string `json:"link"`
Content string `json:"content,omitempty"`
Date time.Time `json:"date"`
Status ItemStatus `json:"status"`
MediaLinks MediaLinks `json:"media_links"`
}
type ItemFilter struct {
@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -16,7 +16,6 @@ func settingsDefaults() map[string]interface{} {
"theme_font": "",
"theme_size": 1,
"refresh_rate": 0,
"expiration_rate": 0,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -143,24 +143,19 @@ 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,
FeedId: feed.Id,
Title: item.Title,
Link: item.URL,
Content: item.Content,
Date: item.Date,
Status: storage.UNREAD,
ImageURL: imageURL,
AudioURL: audioURL,
GUID: item.GUID,
FeedId: feed.Id,
Title: item.Title,
Link: item.URL,
Content: item.Content,
Date: item.Date,
Status: storage.UNREAD,
MediaLinks: mediaLinks,
}
}
return result