aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/neko/main.go19
-rw-r--r--models/item/item.go30
-rw-r--r--models/item/item_test.go76
3 files changed, 117 insertions, 8 deletions
diff --git a/cmd/neko/main.go b/cmd/neko/main.go
index 3fd03f6..cc724df 100644
--- a/cmd/neko/main.go
+++ b/cmd/neko/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "flag"
"fmt"
"os"
"time"
@@ -10,8 +11,7 @@ import (
"adammathes.com/neko/internal/exporter"
"adammathes.com/neko/models"
"adammathes.com/neko/models/feed"
-
- "flag"
+ "adammathes.com/neko/models/item"
"adammathes.com/neko/internal/safehttp"
"adammathes.com/neko/internal/vlog"
@@ -31,6 +31,8 @@ func Run(args []string) error {
var help, update, verbose, proxyImages, secureCookies bool
var configFile, dbfile, newFeed, export, password string
var port, minutes int
+ var purge int
+ var purgeUnread bool
f := flag.NewFlagSet("neko", flag.ContinueOnError)
@@ -51,6 +53,9 @@ func Run(args []string) error {
f.StringVar(&export, "export", "", "export feed: text, opml, html, json")
f.StringVar(&export, "x", "", "export feed (short)")
+ f.IntVar(&purge, "purge", 0, "purge read items older than N days")
+ f.BoolVar(&purgeUnread, "purge-unread", false, "when purging, also include unread items")
+
// options
f.StringVar(&dbfile, "database", "", "sqlite database file")
f.StringVar(&dbfile, "d", "", "sqlite database file (short)")
@@ -126,6 +131,16 @@ func Run(args []string) error {
models.InitDB()
+ if purge > 0 {
+ vlog.Printf("purging items older than %d days (include unread: %t)\n", purge, purgeUnread)
+ affected, err := item.Purge(purge, purgeUnread)
+ if err != nil {
+ return err
+ }
+ vlog.Printf("purged %d items\n", affected)
+ return nil
+ }
+
if update {
vlog.Printf("starting crawl\n")
crawler.Crawl()
diff --git a/models/item/item.go b/models/item/item.go
index 3722e90..f960c65 100644
--- a/models/item/item.go
+++ b/models/item/item.go
@@ -57,8 +57,8 @@ func (i *Item) Print() {
func (i *Item) Create() error {
res, err := models.DB.Exec(`INSERT INTO
- item(title, url, description, publish_date, feed_id)
- VALUES(?, ?, ?, ?, ?)`, i.Title, i.Url, i.Description, i.PublishDate, i.FeedId)
+ item(title, url, description, publish_date, feed_id, read_state, starred)
+ VALUES(?, ?, ?, ?, ?, ?, ?)`, i.Title, i.Url, i.Description, i.PublishDate, i.FeedId, i.ReadState, i.Starred)
if err != nil {
vlog.Printf("Error on item.Create\n%v\n%v\n", i.Url, err)
return err
@@ -230,6 +230,32 @@ func Filter(max_id int64, feed_id int64, category string, unread_only bool, star
return items, nil
}
+// Purge deletes items older than the specified number of days.
+// By default it only deletes read items.
+// If allItems is true, it also deletes unread items.
+// Starred items are NEVER deleted.
+func Purge(days int, allItems bool) (int64, error) {
+ query := `DELETE FROM item WHERE datetime(publish_date) < datetime('now', ?) AND starred == 0`
+ if !allItems {
+ query = query + ` AND read_state == 1`
+ }
+ vlog.Printf("Purge query: %s with param %s\n", query, fmt.Sprintf("-%d days", days))
+ res, err := models.DB.Exec(query, fmt.Sprintf("-%d days", days))
+ if err != nil {
+ return 0, err
+ }
+ affected, _ := res.RowsAffected()
+ vlog.Printf("Purge affected rows: %d\n", affected)
+
+ // Cleanup FTS table - SQLite FTS4 doesn't automatically cleanup content table rows
+ // if we are using "content=item" (which we are).
+ // Actually we have triggers, so it should be fine.
+ // But VACUUM is good to reclaim space.
+ // _, _ = models.DB.Exec("VACUUM")
+
+ return affected, nil
+}
+
func (i *Item) CleanHeaderImage() {
// TODO: blacklist of bad imgs
if i.HeaderImage == "https://s0.wp.com/i/blank.jpg" {
diff --git a/models/item/item_test.go b/models/item/item_test.go
index 805c588..2f31ac7 100644
--- a/models/item/item_test.go
+++ b/models/item/item_test.go
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
goose "github.com/advancedlogic/GoOse"
@@ -638,16 +639,83 @@ func TestRewriteImagesSrcset(t *testing.T) {
}
func TestItemSaveError(t *testing.T) {
- setupTestDB(t)
+ config.Config.DBFile = filepath.Join(t.TempDir(), "test_bad.db")
+ models.InitDB()
+
i := &Item{Id: 1}
- // Close DB to force error
models.DB.Close()
- i.Save() // Should not panic, just log error
+ i.Save()
}
func TestItemFullSaveError(t *testing.T) {
- setupTestDB(t)
+ config.Config.DBFile = filepath.Join(t.TempDir(), "test_bad_2.db")
+ models.InitDB()
i := &Item{Id: 1}
models.DB.Close()
i.FullSave() // Should not panic
}
+func TestPurge(t *testing.T) {
+ setupTestDB(t)
+ feedId := createTestFeed(t)
+
+ // Create items with different dates and states
+ now := time.Now()
+ // Use explicit dates, ensure SQLite parses them correctly
+ // Old date: 60 days ago
+ oldDate := now.AddDate(0, 0, -60).Format("2006-01-02 15:04:05")
+ // Very old date: 100 days ago
+ veryOldDate := now.AddDate(0, 0, -100).Format("2006-01-02 15:04:05")
+
+ items := []*Item{
+ {Title: "Old Read", Url: "http://example.com/1", PublishDate: oldDate, FeedId: feedId, ReadState: true},
+ {Title: "Old Unread", Url: "http://example.com/2", PublishDate: oldDate, FeedId: feedId, ReadState: false},
+ {Title: "Old Starred", Url: "http://example.com/3", PublishDate: oldDate, FeedId: feedId, ReadState: true, Starred: true},
+ {Title: "Very Old Read", Url: "http://example.com/4", PublishDate: veryOldDate, FeedId: feedId, ReadState: true},
+ }
+
+ for _, i := range items {
+ err := i.Create()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Purge read items older than 30 days
+ affected, err := Purge(30, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Should have purged "Old Read" and "Very Old Read"
+ if affected != 2 {
+ t.Errorf("Expected 2 items purged, got %d", affected)
+ // Debug logging to see what's left
+ remaining, _ := Filter(0, 0, "", false, false, 0, "")
+ for _, r := range remaining {
+ t.Logf("Remaining: %s (%s) Read: %t Starred: %t", r.Title, r.PublishDate, r.ReadState, r.Starred)
+ }
+ }
+
+ // Verify remaining items count
+ remaining, _ := Filter(0, 0, "", false, false, 0, "")
+ if len(remaining) != 2 {
+ t.Errorf("Expected 2 items remaining, got %d", len(remaining))
+ }
+
+ // Purge all items older than 30 days (including unread)
+ affected, err = Purge(30, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Should have purged "Old Unread"
+ if affected != 1 {
+ t.Errorf("Expected 1 item purged, got %d", affected)
+ }
+
+ //Verify "Old Starred" is still there
+ remaining, _ = Filter(0, 0, "", false, false, 0, "")
+ if len(remaining) != 1 || remaining[0].Title != "Old Starred" {
+ t.Errorf("Expected only 'Old Starred' to remain, got %v", remaining)
+ }
+}