diff --git a/src/storage/interface.go b/src/storage/factory/factory.go similarity index 77% rename from src/storage/interface.go rename to src/storage/factory/factory.go index eea5d45..4d4b315 100644 --- a/src/storage/interface.go +++ b/src/storage/factory/factory.go @@ -1,22 +1,27 @@ -package storage +package factory + +import ( + "github.com/nkanaev/yarr/src/storage/model" +) type Storage interface { Close() error + Migrate() error CountItems() int - CreateFeed(params CreateFeedParams) *Feed + CreateFeed(params model.CreateFeedParams) *model.Feed CreateFolder(title string) *Folder CreateItems(items []Item) bool DeleteFeed(feedId int64) bool DeleteFolder(folderId int64) bool DeleteOldItems() FeedStats() []FeedStat - GetFeed(id int64) *Feed + GetFeed(id int64) *model.Feed GetFeedState(feedID int64) (*FeedState, error) GetItem(id int64) *Item GetSettings() Settings ListFeedStates() ([]FeedState, error) - ListFeeds() []Feed - ListFolders() []Folder + ListFeeds() []model.Feed + ListFolders() []model.Folder ListItems(filter ItemFilter, limit int, newestFirst bool, withContent bool) []Item MarkItemsRead(filter MarkFilter) bool UpdateFeed(feedId int64, params UpdateFeedParams) (bool, error) @@ -25,4 +30,3 @@ type Storage interface { UpdateItemStatus(item_id int64, status ItemStatus) bool UpdateSettings(params UpdateSettingsParams) bool } - diff --git a/src/storage/model/model.go b/src/storage/model/model.go new file mode 100644 index 0000000..24a1eeb --- /dev/null +++ b/src/storage/model/model.go @@ -0,0 +1,182 @@ +package model + +import ( + "encoding/json" + "time" +) + +type Feed struct { + Id int64 `json:"id"` + FolderId *int64 `json:"folder_id"` + Title string `json:"title"` + Description string `json:"description"` + Link string `json:"link"` + FeedLink string `json:"feed_link"` + Icon *[]byte `json:"icon,omitempty"` + HasIcon bool `json:"has_icon"` +} + +type CreateFeedParams struct { + Title string + Description string + Link string + FeedLink string + FolderID *int64 +} + +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"` + MediaLinks MediaLinks `json:"media_links"` +} + +type ItemStatus int + +const ( + UNREAD ItemStatus = 0 + READ ItemStatus = 1 + STARRED ItemStatus = 2 +) + + +var StatusRepresentations = map[ItemStatus]string{ + UNREAD: "unread", + READ: "read", + STARRED: "starred", +} + +var StatusValues = map[string]ItemStatus{ + "unread": UNREAD, + "read": READ, + "starred": STARRED, +} + +func (s ItemStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(StatusRepresentations[s]) +} + +func (s *ItemStatus) UnmarshalJSON(b []byte) error { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + *s = StatusValues[str] + return nil +} + +type MediaLink struct { + URL string `json:"url"` + Type string `json:"type"` + Description string `json:"description,omitempty"` +} + +type MediaLinks []MediaLink + +type ItemFilter struct { + FolderID *int64 + FeedID *int64 + Status *ItemStatus + Search *string + After *int64 + IDs *[]int64 + SinceID *int64 + MaxID *int64 + Before *time.Time +} + +type MarkFilter struct { + FolderID *int64 + FeedID *int64 + + Before *time.Time +} + +type Folder struct { + Id int64 `json:"id"` + Title string `json:"title"` + IsExpanded bool `json:"is_expanded"` +} + +type UpdateFolderParams struct { + Title *string + IsExpanded *bool +} + + +type Settings struct { + Filter string `json:"filter"` + Feed string `json:"feed"` + FeedListWidth int `json:"feed_list_width"` + ItemListWidth int `json:"item_list_width"` + SortNewestFirst bool `json:"sort_newest_first"` + ThemeName string `json:"theme_name"` + ThemeFont string `json:"theme_font"` + ThemeSize int `json:"theme_size"` + RefreshRate int64 `json:"refresh_rate"` + Language string `json:"language"` +} + +type UpdateSettingsParams struct { + Filter *string `json:"filter"` + Feed *string `json:"feed"` + FeedListWidth *int `json:"feed_list_width"` + ItemListWidth *int `json:"item_list_width"` + SortNewestFirst *bool `json:"sort_newest_first"` + ThemeName *string `json:"theme_name"` + ThemeFont *string `json:"theme_font"` + ThemeSize *int `json:"theme_size"` + RefreshRate *int64 `json:"refresh_rate"` + Language *string `json:"language"` +} + +func (s Settings) Map() map[string]any { + return map[string]any{ + "filter": s.Filter, + "feed": s.Feed, + "feed_list_width": s.FeedListWidth, + "item_list_width": s.ItemListWidth, + "sort_newest_first": s.SortNewestFirst, + "theme_name": s.ThemeName, + "theme_font": s.ThemeFont, + "theme_size": s.ThemeSize, + "refresh_rate": s.RefreshRate, + "language": s.Language, + } +} + +type FeedState struct { + FeedID int64 + LastRefreshed time.Time + LastError string + HTTPLastModified string + HTTPEtag string +} + +type UpdateFeedStateParams struct { + LastRefreshed *time.Time + LastError *string + HTTPLastModified *string + HTTPEtag *string +} + +type UpdateFeedParams struct { + Title *string + FeedLink *string + FolderID Nullable[int64] + Icon Nullable[[]byte] +} + +type Nullable[T any] struct { + Set bool + Value *T +} + +func SetNullable[T any](v *T) Nullable[T] { + return Nullable[T]{Set: true, Value: v} +} diff --git a/src/storage/sqlite/feed.go b/src/storage/sqlite/feed.go index 5df9498..e1decc2 100644 --- a/src/storage/sqlite/feed.go +++ b/src/storage/sqlite/feed.go @@ -3,28 +3,11 @@ package sqlite import ( "database/sql" "log" + + "github.com/nkanaev/yarr/src/storage/model" ) -type Feed struct { - Id int64 `json:"id"` - FolderId *int64 `json:"folder_id"` - Title string `json:"title"` - Description string `json:"description"` - Link string `json:"link"` - FeedLink string `json:"feed_link"` - Icon *[]byte `json:"icon,omitempty"` - HasIcon bool `json:"has_icon"` -} - -type CreateFeedParams struct { - Title string - Description string - Link string - FeedLink string - FolderID *int64 -} - -func (s *SQLiteStorage) CreateFeed(params CreateFeedParams) *Feed { +func (s *SQLiteStorage) CreateFeed(params model.CreateFeedParams) *model.Feed { title := params.Title if title == "" { title = params.FeedLink @@ -73,13 +56,6 @@ func (s *SQLiteStorage) DeleteFeed(feedId int64) bool { return nrows == 1 } -type UpdateFeedParams struct { - Title *string - FeedLink *string - FolderID Nullable[int64] - Icon Nullable[[]byte] -} - func (s *SQLiteStorage) UpdateFeed(feedId int64, params UpdateFeedParams) (bool, error) { _, err := s.db.Exec(` update feeds set @@ -104,8 +80,8 @@ func (s *SQLiteStorage) UpdateFeed(feedId int64, params UpdateFeedParams) (bool, return true, nil } -func (s *SQLiteStorage) ListFeeds() []Feed { - result := make([]Feed, 0) +func (s *SQLiteStorage) ListFeeds() []model.Feed { + result := make([]model.Feed, 0) rows, err := s.db.Query(` select id, folder_id, title, description, link, feed_link, ifnull(length(icon), 0) > 0 as has_icon @@ -117,7 +93,7 @@ func (s *SQLiteStorage) ListFeeds() []Feed { return result } for rows.Next() { - var f Feed + var f model.Feed err = rows.Scan( &f.Id, &f.FolderId, diff --git a/src/storage/sqlite/feedstate.go b/src/storage/sqlite/feedstate.go index 60e4852..1b4ba4b 100644 --- a/src/storage/sqlite/feedstate.go +++ b/src/storage/sqlite/feedstate.go @@ -5,14 +5,6 @@ import ( "time" ) -type FeedState struct { - FeedID int64 - LastRefreshed time.Time - LastError string - HTTPLastModified string - HTTPEtag string -} - func (s *SQLiteStorage) ListFeedStates() ([]FeedState, error) { rows, err := s.db.Query(` select @@ -72,13 +64,6 @@ func (s *SQLiteStorage) GetFeedState(feedID int64) (*FeedState, error) { return &state, nil } -type UpdateFeedStateParams struct { - LastRefreshed *time.Time - LastError *string - HTTPLastModified *string - HTTPEtag *string -} - func (s *SQLiteStorage) UpdateFeedState(feedID int64, params UpdateFeedStateParams) (bool, error) { lastError := params.LastError if lastError != nil && *lastError == "" { diff --git a/src/storage/sqlite/folder.go b/src/storage/sqlite/folder.go index ac3eedf..9ce66ea 100644 --- a/src/storage/sqlite/folder.go +++ b/src/storage/sqlite/folder.go @@ -5,12 +5,6 @@ import ( "log" ) -type Folder struct { - Id int64 `json:"id"` - Title string `json:"title"` - IsExpanded bool `json:"is_expanded"` -} - func (s *SQLiteStorage) CreateFolder(title string) *Folder { expanded := true row := s.db.QueryRow(` @@ -38,11 +32,6 @@ func (s *SQLiteStorage) DeleteFolder(folderId int64) bool { return err == nil } -type UpdateFolderParams struct { - Title *string - IsExpanded *bool -} - func (s *SQLiteStorage) UpdateFolder(folderId int64, params UpdateFolderParams) (bool, error) { _, err := s.db.Exec(` update folders set diff --git a/src/storage/sqlite/item.go b/src/storage/sqlite/item.go index a6c762f..f161fba 100644 --- a/src/storage/sqlite/item.go +++ b/src/storage/sqlite/item.go @@ -1,57 +1,20 @@ package sqlite import ( + "cmp" "database/sql" "database/sql/driver" "encoding/json" "fmt" "log" - "sort" + "slices" "strings" "time" + + "github.com/nkanaev/yarr/src/storage/model" ) -type ItemStatus int - -const ( - UNREAD ItemStatus = 0 - READ ItemStatus = 1 - STARRED ItemStatus = 2 -) - -var StatusRepresentations = map[ItemStatus]string{ - UNREAD: "unread", - READ: "read", - STARRED: "starred", -} - -var StatusValues = map[string]ItemStatus{ - "unread": UNREAD, - "read": READ, - "starred": STARRED, -} - -func (s ItemStatus) MarshalJSON() ([]byte, error) { - return json.Marshal(StatusRepresentations[s]) -} - -func (s *ItemStatus) UnmarshalJSON(b []byte) error { - var str string - if err := json.Unmarshal(b, &str); err != nil { - return err - } - *s = StatusValues[str] - return nil -} - -type MediaLink struct { - URL string `json:"url"` - Type string `json:"type"` - Description string `json:"description,omitempty"` -} - -type MediaLinks []MediaLink - +// TODO: serialize/deserialize func (m *MediaLinks) Scan(src any) error { switch data := src.(type) { case []byte: @@ -67,55 +30,6 @@ 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"` - MediaLinks MediaLinks `json:"media_links"` -} - -type ItemFilter struct { - FolderID *int64 - FeedID *int64 - Status *ItemStatus - Search *string - After *int64 - IDs *[]int64 - SinceID *int64 - MaxID *int64 - Before *time.Time -} - -type MarkFilter struct { - FolderID *int64 - FeedID *int64 - - Before *time.Time -} - -type ItemList []Item - -func (list ItemList) Len() int { - return len(list) -} - -func (list ItemList) SortKey(i int) string { - return list[i].Date.Format(time.RFC3339) + "::" + list[i].GUID -} - -func (list ItemList) Less(i, j int) bool { - return list.SortKey(i) < list.SortKey(j) -} - -func (list ItemList) Swap(i, j int) { - list[i], list[j] = list[j], list[i] -} - func (s *SQLiteStorage) CreateItems(items []Item) bool { tx, err := s.db.Begin() if err != nil { @@ -125,10 +39,13 @@ func (s *SQLiteStorage) CreateItems(items []Item) bool { now := time.Now().UTC() - itemsSorted := ItemList(items) - sort.Sort(itemsSorted) + slices.SortStableFunc(items, func(a, b model.Item) int { + sa := a.Date.Format(time.RFC3339) + "::" + a.GUID + sb := b.Date.Format(time.RFC3339) + "::" + b.GUID + return cmp.Compare(sa, sb) + }) - for _, item := range itemsSorted { + for _, item := range items { _, err = tx.Exec(` insert into items ( guid, feed_id, title, link, date, @@ -151,7 +68,7 @@ func (s *SQLiteStorage) CreateItems(items []Item) bool { sql.Named("media_links", item.MediaLinks), sql.Named("date_arrived", now), sql.Named("last_arrived", now), - sql.Named("status", UNREAD), + sql.Named("status", model.UNREAD), ) if err != nil { log.Print(err) diff --git a/src/storage/sqlite/settings.go b/src/storage/sqlite/settings.go index 7fdce0e..c1e65ac 100644 --- a/src/storage/sqlite/settings.go +++ b/src/storage/sqlite/settings.go @@ -6,34 +6,6 @@ import ( "log" ) -type Settings struct { - Filter string `json:"filter"` - Feed string `json:"feed"` - FeedListWidth int `json:"feed_list_width"` - ItemListWidth int `json:"item_list_width"` - SortNewestFirst bool `json:"sort_newest_first"` - ThemeName string `json:"theme_name"` - ThemeFont string `json:"theme_font"` - ThemeSize int `json:"theme_size"` - RefreshRate int64 `json:"refresh_rate"` - Language string `json:"language"` -} - -func (s Settings) Map() map[string]any { - return map[string]any{ - "filter": s.Filter, - "feed": s.Feed, - "feed_list_width": s.FeedListWidth, - "item_list_width": s.ItemListWidth, - "sort_newest_first": s.SortNewestFirst, - "theme_name": s.ThemeName, - "theme_font": s.ThemeFont, - "theme_size": s.ThemeSize, - "refresh_rate": s.RefreshRate, - "language": s.Language, - } -} - func settingsDefaults() Settings { return Settings{ Filter: "", @@ -89,19 +61,6 @@ func (s *SQLiteStorage) GetSettings() Settings { return result } -type UpdateSettingsParams struct { - Filter *string `json:"filter"` - Feed *string `json:"feed"` - FeedListWidth *int `json:"feed_list_width"` - ItemListWidth *int `json:"item_list_width"` - SortNewestFirst *bool `json:"sort_newest_first"` - ThemeName *string `json:"theme_name"` - ThemeFont *string `json:"theme_font"` - ThemeSize *int `json:"theme_size"` - RefreshRate *int64 `json:"refresh_rate"` - Language *string `json:"language"` -} - func (s *SQLiteStorage) UpdateSettings(params UpdateSettingsParams) bool { tx, err := s.db.Begin() if err != nil { diff --git a/src/storage/sqlite/storage.go b/src/storage/sqlite/storage.go index 817e343..a06976d 100644 --- a/src/storage/sqlite/storage.go +++ b/src/storage/sqlite/storage.go @@ -21,15 +21,6 @@ type SQLiteStorage struct { db *sql.DB } -type Nullable[T any] struct { - Set bool - Value *T -} - -func SetNullable[T any](v *T) Nullable[T] { - return Nullable[T]{Set: true, Value: v} -} - func New(path string) (*SQLiteStorage, error) { if pos := strings.IndexRune(path, '?'); pos == -1 { params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"