// This file is part of beagles.
//
// Copyright © 2020 Chris Palmer <chris@red-oxide.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package db
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"git.sr.ht/~chrisppy/beagles/util"
"git.sr.ht/~chrisppy/go-barefeed"
"git.sr.ht/~chrisppy/go-opml"
bolt "go.etcd.io/bbolt"
)
// Storage contains the main data needed to run the app.
type Storage struct {
Feeds Feeds
Items Items
Queue Queue
Favorites Favorites
Path string
GeminiPath string
Version string
}
func openDB(path string) (*bolt.DB, error) {
if path == "" {
return nil, fmt.Errorf("path is empty")
}
return bolt.Open(filepath.Clean(path), 0600, nil)
}
// ReadDB will grab all data from the database to use while running the app.
func ReadDB(path string, gmniPath string) (*Storage, error) {
db, err := openDB(path)
if err != nil {
return nil, err
}
s := &Storage{
Feeds: make(Feeds),
Items: make(Items),
Queue: make(Queue),
Favorites: make(Favorites),
Path: path,
GeminiPath: gmniPath,
}
if err := s.Feeds.Read(db); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
}
if err := s.Items.Read(db); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
}
if err := s.Queue.Read(db); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
}
if err := s.Favorites.Read(db); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
}
if err := db.Close(); err != nil {
return nil, err
}
return s, nil
}
// Import will use an OPML file to insert feeds that you are not already
// subscribed.
func (s *Storage) Import(path string) error {
o, err := opml.FromFile(path)
if err != nil {
return err
}
errors := make(map[error]bool)
for _, out := range o.Body.Outlines {
if _, ok := s.Feeds[out.XMLURL]; ok {
continue
}
if _, err := s.CreateFeed(out.XMLURL); err != nil {
errors[err] = false
continue
}
}
i := 0
var eb strings.Builder
for e := range errors {
if i > 0 {
eb.WriteString("\n")
}
eb.WriteString(e.Error())
i++
}
if eb.Len() > 0 {
return fmt.Errorf(eb.String())
}
return nil
}
// Export will export all feeds to an OPML file.
func (s *Storage) Export(path string) error {
path = filepath.Join(path, "beagles.opml")
o := &opml.OPML{
Version: "2.0",
Head: opml.Head{
Title: fmt.Sprintf("beagles v%s subscriptions", s.Version),
DateCreated: time.Now().Format(time.RFC822),
},
Body: opml.Body{
Outlines: make([]*opml.Outline, len(s.Feeds)),
},
}
i := 0
for k, v := range s.Feeds {
o.Body.Outlines[i] = &opml.Outline{
Text: v.Title,
Title: v.Title,
Type: "rss",
HTMLURL: v.Link,
XMLURL: k,
}
i++
}
return o.Write(path)
}
// Backup will output all the data needed from the database to the barefeed
// format
func (s *Storage) Backup(path string) error {
path = filepath.Join(path, "beagles.barefeed")
m := barefeed.MessageV1{
Created: barefeed.ToTimestamp(time.Now()),
Generator: fmt.Sprintf("beagles v%s", s.Version),
Feeds: make([]barefeed.Feed, len(s.Feeds)),
}
i := 0
for k, v := range s.Feeds {
d, err := util.StripHTML(v.Description)
if err != nil {
return err
}
f := barefeed.Feed{
Feed: k,
Title: v.Title,
Description: d,
Link: v.Link,
Items: make([]barefeed.Item, len(v.Items)),
}
j := 0
for k1 := range v.Items {
val, ok := s.Items[k1]
if !ok {
return fmt.Errorf("database is out of sync")
}
c, err := util.StripHTML(val.Content)
if err != nil {
return nil
}
it := barefeed.Item{
Link: k1,
Title: val.Title,
Content: c,
Read: val.Read,
Favorite: val.Favorite,
Date: barefeed.ToTimestamp(val.Date),
}
if val.Type != "" || val.Location != "" || val.Length != "" {
it.Media = &barefeed.Media{
Location: val.Location,
Mimetype: val.Type,
Position: int64(val.PlaybackPOS),
}
if l, err := strconv.Atoi(val.Length); err != nil {
it.Media.Length = int64(0)
} else {
it.Media.Length = int64(l)
}
}
f.Items[j] = it
j++
}
m.Feeds[i] = f
i++
}
return barefeed.WriteFile(m, path)
}
// CreateFeed will collect the rss feed and process through the elements
// and add the relevant data elements to the database
func (s *Storage) CreateFeed(url string) (Items, error) {
db, err := openDB(s.Path)
if err != nil {
return nil, err
}
items, err := s.Feeds.Insert(db, url, s.GeminiPath)
if err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
nitems := make(Items)
for _, item := range items {
it, err := s.Items.ProcessInsert(db, item, url)
if err != nil {
if err := db.Close(); err != nil {
return nil, err
}
}
key := item.Link
if err := s.Queue.Insert(db, key); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
}
nitems[key] = it
}
if err := db.Close(); err != nil {
return nil, err
}
return nitems, nil
}
// DeleteFeed will delete the Feed, Items, and relevant items it the queue.
func (s *Storage) DeleteFeed(url string) (*Feed, Items, error) {
db, err := openDB(s.Path)
if err != nil {
return nil, nil, err
}
feed, err := s.Feeds.Delete(db, url)
if err != nil {
if err := db.Close(); err != nil {
return nil, nil, err
}
return nil, nil, err
}
nitems := make(Items)
for key := range feed.Items {
it, err := s.Items.Delete(db, key)
if err != nil {
if err := db.Close(); err != nil {
return nil, nil, err
}
return nil, nil, err
}
if err := s.Queue.Delete(db, key); err != nil {
if err := db.Close(); err != nil {
return nil, nil, err
}
return nil, nil, err
}
if err := s.Favorites.Delete(db, key); err != nil {
if err := db.Close(); err != nil {
return nil, nil, err
}
return nil, nil, err
}
nitems[key] = it
}
if err := db.Close(); err != nil {
return nil, nil, err
}
return feed, nitems, nil
}
// Update will check for rss updates
func (s *Storage) Update() (Items, error) {
db, err := openDB(s.Path)
if err != nil {
return nil, fmt.Errorf("unable to open db: %s", err.Error())
}
nitems := make(Items)
for _, f := range s.Feeds {
updateURL := f.UpdateURL
items, err := f.FindNewItems(s.Items, s.GeminiPath)
if err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, fmt.Errorf("error finding new items for `%s`: %s", updateURL, err.Error())
}
if err := s.Feeds.AddItems(db, updateURL, items); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
for _, item := range items {
it, err := s.Items.Insert(db, item)
if err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
if err := s.Queue.Insert(db, it.Link); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
nitems[item.Link] = item
}
}
if err := db.Close(); err != nil {
return nil, err
}
return nitems, nil
}
// MarkRead wil update the Storage to note that an item has been read,
// remove the item from the list.
func (s *Storage) MarkRead(key string) (map[string]bool, error) {
if key == "" {
return nil, fmt.Errorf("key was empty, unable to mark read")
}
itemMap := make(map[string]bool)
if item, ok := s.Items[key]; ok {
if item.Read {
return nil, nil
}
itemMap[key] = false
} else if feed, ok := s.Feeds[key]; ok {
for k := range feed.Items {
itemMap[k] = false
}
} else {
return nil, fmt.Errorf("key not found, unable to mark read")
}
db, err := openDB(s.Path)
if err != nil {
return nil, fmt.Errorf("unable to open db: %s", err.Error())
}
for k := range itemMap {
item := s.Items[k]
if item.Read {
continue
}
if err := s.Items.ToggleRead(db, k); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
if err := s.Queue.Delete(db, k); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
}
if err := db.Close(); err != nil {
return nil, err
}
return itemMap, nil
}
// MarkUnread wil update the Storage to note that an item has been unread,
// add the item from the list.
func (s *Storage) MarkUnread(key string) (Items, error) {
if key == "" {
return nil, fmt.Errorf("key was empty, unable to mark unread")
}
itemMap := make(Items)
if item, ok := s.Items[key]; ok {
if !item.Read {
return nil, nil
}
itemMap[key] = item
} else if feed, ok := s.Feeds[key]; ok {
for k := range feed.Items {
if item, ok := s.Items[k]; ok {
if !item.Read {
continue
}
itemMap[key] = item
}
}
} else {
return nil, fmt.Errorf("key not found, unable to mark unread")
}
db, err := openDB(s.Path)
if err != nil {
return nil, fmt.Errorf("unable to open db: %s", err.Error())
}
for k := range itemMap {
if !s.Items[k].Read {
continue
}
if err := s.Items.ToggleRead(db, k); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
if err := s.Queue.Insert(db, k); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
}
if err := db.Close(); err != nil {
return nil, err
}
return itemMap, nil
}
// Favorite wil update the Storage to note that an item has been added to
// favorites.
func (s *Storage) Favorite(key string) (*Item, error) {
if key == "" {
return nil, fmt.Errorf("key was empty, unable to favorite")
}
item := s.Items[key]
if item.Favorite {
return nil, nil
}
db, err := openDB(s.Path)
if err != nil {
return nil, fmt.Errorf("unable to open db: %s", err.Error())
}
if err := s.Items.ToggleFavorite(db, key); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
if err := s.Favorites.Insert(db, key); err != nil {
if err := db.Close(); err != nil {
return nil, err
}
return nil, err
}
if err := db.Close(); err != nil {
return nil, err
}
return item, nil
}
// Unfavorite wil update the Storage to note that an item has been removed from
// favorites.
func (s *Storage) Unfavorite(key string) error {
if key == "" {
return fmt.Errorf("key was empty, unable to favorite")
}
item := s.Items[key]
if !item.Favorite {
return nil
}
db, err := openDB(s.Path)
if err != nil {
return fmt.Errorf("unable to open db: %s", err.Error())
}
if err := s.Items.ToggleFavorite(db, key); err != nil {
if err := db.Close(); err != nil {
return err
}
return err
}
if err := s.Favorites.Delete(db, key); err != nil {
if err := db.Close(); err != nil {
return err
}
return err
}
if err := db.Close(); err != nil {
return err
}
return nil
}