Merge b92ad6b5803d10eab304db016a7cf135043c5b83 into 0e88d4284d96338ded25e0b5acac156b7ea1288a

This commit is contained in:
Adam Szkoda 2025-03-04 13:15:53 +00:00 committed by GitHub
commit e49f9910d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 250 additions and 3 deletions

View File

@ -224,6 +224,15 @@
<span class="icon mr-1">{% inline "edit.svg" %}</span> <span class="icon mr-1">{% inline "edit.svg" %}</span>
Change Link Change Link
</button> </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> <div class="dropdown-divider"></div>
<header class="dropdown-header">Move to...</header> <header class="dropdown-header">Move to...</header>
<button class="dropdown-item" <button class="dropdown-item"

View File

@ -52,6 +52,9 @@
list_errors: function() { list_errors: function() {
return api('get', './api/feeds/errors').then(json) return api('get', './api/feeds/errors').then(json)
}, },
get_expire_minutes: function(id) {
return api('get', './api/feeds/' + id + '/expire').then(json)
},
}, },
folders: { folders: {
list: function() { list: function() {

View File

@ -250,6 +250,8 @@ var vm = new Vue({
'size': s.theme_size, 'size': s.theme_size,
}, },
'refreshRate': s.refresh_rate, 'refreshRate': s.refresh_rate,
'expireUnreads': s.expiration_rate,
'feedExpire': 0,
'authenticated': app.authenticated, 'authenticated': app.authenticated,
'feed_errors': {}, 'feed_errors': {},
} }
@ -333,8 +335,18 @@ var vm = new Vue({
}, },
'feedSelected': function(newVal, oldVal) { 'feedSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false)) api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
this.itemSelected = null 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 if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
}, },
'itemSelected': function(newVal, oldVal) { 'itemSelected': function(newVal, oldVal) {
@ -496,6 +508,14 @@ var vm = new Vue({
} }
return new Date(datestr).toLocaleDateString(undefined, options) 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) { moveFeed: function(feed, folder) {
var folder_id = folder ? folder.id : null var folder_id = folder ? folder.id : null
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() { api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {

75
src/expirer/expirer.go Normal file
View File

@ -0,0 +1,75 @@
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

@ -51,6 +51,7 @@ func (s *Server) handler() http.Handler {
r.For("/api/feeds/errors", s.handleFeedErrors) r.For("/api/feeds/errors", s.handleFeedErrors)
r.For("/api/feeds/:id/icon", s.handleFeedIcon) r.For("/api/feeds/:id/icon", s.handleFeedIcon)
r.For("/api/feeds/:id", s.handleFeed) 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", s.handleItemList)
r.For("/api/items/:id", s.handleItem) r.For("/api/items/:id", s.handleItem)
r.For("/api/settings", s.handleSettings) r.For("/api/settings", s.handleSettings)
@ -263,13 +264,54 @@ 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) { func (s *Server) handleFeed(c *router.Context) {
id, err := c.VarInt64("id") id, err := c.VarInt64("id")
if err != nil { if err != nil {
c.Out.WriteHeader(http.StatusBadRequest) c.Out.WriteHeader(http.StatusBadRequest)
return return
} }
if c.Req.Method == "PUT" { 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" {
feed := s.db.GetFeed(id) feed := s.db.GetFeed(id)
if feed == nil { if feed == nil {
c.Out.WriteHeader(http.StatusBadRequest) c.Out.WriteHeader(http.StatusBadRequest)
@ -299,6 +341,15 @@ func (s *Server) handleFeed(c *router.Context) {
s.db.UpdateFeedLink(id, link.(string)) 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) 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)
@ -417,6 +468,10 @@ func (s *Server) handleSettings(c *router.Context) {
if _, ok := settings["refresh_rate"]; ok { if _, ok := settings["refresh_rate"]; ok {
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate")) 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) c.Out.WriteHeader(http.StatusOK)
} else { } else {
c.Out.WriteHeader(http.StatusBadRequest) c.Out.WriteHeader(http.StatusBadRequest)

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"sync" "sync"
"github.com/nkanaev/yarr/src/expirer"
"github.com/nkanaev/yarr/src/storage" "github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/worker" "github.com/nkanaev/yarr/src/worker"
) )
@ -13,6 +14,7 @@ type Server struct {
Addr string Addr string
db *storage.Storage db *storage.Storage
worker *worker.Worker worker *worker.Worker
expirer *expirer.Expirer
cache map[string]interface{} cache map[string]interface{}
cache_mutex *sync.Mutex cache_mutex *sync.Mutex
@ -31,6 +33,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),
expirer: expirer.NewExpirer(db),
cache: make(map[string]interface{}), cache: make(map[string]interface{}),
cache_mutex: &sync.Mutex{}, cache_mutex: &sync.Mutex{},
} }
@ -53,6 +56,9 @@ func (s *Server) Start() {
s.worker.RefreshFeeds() s.worker.RefreshFeeds()
} }
globalExpirationPeriod := s.db.GetSettingsValueInt64("expiration_rate")
s.expirer.StartUnreadsExpirer(uint64(globalExpirationPeriod))
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()} httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()}
var err error var err error

View File

@ -21,7 +21,7 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
title = feedLink title = 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)
values (?, ?, ?, ?, ?) values (?, ?, ?, ?, ?)
on conflict (feed_link) do update set folder_id = ? on conflict (feed_link) do update set folder_id = ?
returning id`, returning id`,
@ -76,6 +76,31 @@ func (s *Storage) UpdateFeedLink(feedId int64, newLink string) bool {
return err == nil 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 { func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId) _, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
return err == nil return err == nil

View File

@ -372,6 +372,51 @@ 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 ( var (
itemsKeepSize = 50 itemsKeepSize = 50
itemsKeepDays = 90 itemsKeepDays = 90

View File

@ -17,6 +17,7 @@ var migrations = []func(*sql.Tx) error{
m07_add_feed_size, m07_add_feed_size,
m08_normalize_datetime, m08_normalize_datetime,
m09_change_item_index, m09_change_item_index,
m10_per_feed_expiration,
} }
var maxVersion = int64(len(migrations)) var maxVersion = int64(len(migrations))
@ -103,7 +104,8 @@ func m01_initial(tx *sql.Tx) error {
description text, description text,
link text, link text,
feed_link text not null, feed_link text not null,
icon blob icon blob,
expire_minutes integer not null default 0
); );
create index if not exists idx_feed_folder_id on feeds(folder_id); create index if not exists idx_feed_folder_id on feeds(folder_id);
@ -306,3 +308,9 @@ func m09_change_item_index(tx *sql.Tx) error {
_, err := tx.Exec(sql) _, err := tx.Exec(sql)
return err return err
} }
func m10_per_feed_expiration(tx *sql.Tx) error {
sql := `alter table feeds add column expire_minutes integer not null default 0;`
_, err := tx.Exec(sql)
return err
}

View File

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