diff --git a/core/items.go b/core/items.go new file mode 100644 index 0000000..5e695dd --- /dev/null +++ b/core/items.go @@ -0,0 +1,200 @@ +package core + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + + _ "github.com/mattn/go-sqlite3" +) + +func AddItems(db DB, items []Item) error { + return db.Transact(func(tx DB) error { + stmt, err := tx.Prepare(` + insert into items (source, id, active, title, author, body, link, time, action) + values (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?)) + `) + if err != nil { + return fmt.Errorf("failed to prepare insert: %v", err) + } + for _, item := range items { + actions, err := json.Marshal(item.Action) + if err != nil { + return fmt.Errorf("failed to marshal actions for %s/%s: %v", item.Source, item.Id, err) + } + _, err = stmt.Exec(item.Source, item.Id, true, item.Title, item.Author, item.Body, item.Link, item.Time, actions) + if err != nil { + return fmt.Errorf("failed to insert %s/%s: %v", item.Source, item.Id, err) + } + } + return nil + }) +} + +// Set fields in the new item to match the old item where the new item's fields are zero-valued. +// This allows sources to omit fields and let an action set them without a later fetch overwriting +// the value from the action, e.g. an on-create action archiving a page and setting the link to +// point to the archive. +func BackfillItem(new *Item, old *Item) { + new.Active = old.Active + new.Created = old.Created + if new.Author == "" { + new.Author = old.Author + } + if new.Body == "" { + new.Body = old.Body + } + if new.Link == "" { + new.Link = old.Link + } + if new.Time == 0 { + new.Time = old.Time + } + if new.Title == "" { + new.Title = old.Title + } +} + +func UpdateItems(db DB, items []Item) error { + return db.Transact(func(tx DB) error { + stmt, err := tx.Prepare(` + update items + set + title = ?, + author = ?, + body = ?, + link = ?, + time = ?, + action = jsonb(?) + where source = ? + and id = ? + `) + if err != nil { + return err + } + for _, item := range items { + actions, err := json.Marshal(item.Action) + if err != nil { + return fmt.Errorf("failed to marshal actions for %s/%s: %v", item.Source, item.Id, err) + } + _, err = stmt.Exec(item.Title, item.Author, item.Body, item.Link, item.Time, actions, item.Source, item.Id) + if err != nil { + return err + } + } + return nil + }) +} + +// Deactivate an item, returning its previous active state. +func DeactivateItem(db DB, source string, id string) (bool, error) { + row := db.QueryRow(` + select active + from items + where source = ? and id = ? + `, source, id) + var active bool + err := row.Scan(&active) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("item %s/%s not found", source, id) + } + + _, err = db.Exec(` + update items + set active = 0 + where source = ? and id = ? + `, source, id) + if err != nil { + return false, err + } + return active, nil +} + +func DeleteItem(db DB, source string, id string) (int64, error) { + res, err := db.Exec(` + delete from items + where source = ? + and id = ? + `, source, id) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func getItems(db DB, query string, args ...any) ([]Item, error) { + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + var items []Item + for rows.Next() { + var item Item + err = rows.Scan(&item.Source, &item.Id, &item.Created, &item.Active, &item.Title, &item.Author, &item.Body, &item.Link, &item.Time, &item.Action) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func GetItem(db DB, source string, id string) (Item, error) { + items, err := getItems(db, ` + select source, id, created, active, title, author, body, link, time, json(action) + from items + where source = ? + and id = ? + order by case when time = 0 then created else time end, id + `, source, id) + if err != nil { + return Item{}, err + } + if len(items) == 0 { + return Item{}, fmt.Errorf("no item in %s with id %s", source, id) + } + return items[0], nil +} + +func GetAllActiveItems(db DB) ([]Item, error) { + return getItems(db, ` + select + source, id, created, active, title, author, body, link, time, json(action) + from items + where active <> 0 + order by case when time = 0 then created else time end, id + `) +} + +func GetAllItems(db DB) ([]Item, error) { + return getItems(db, ` + select + source, id, created, active, title, author, body, link, time, json(action) + from items + order by case when time = 0 then created else time end, id + `) +} + +func GetActiveItemsForSource(db DB, source string) ([]Item, error) { + return getItems(db, ` + select + source, id, created, active, title, author, body, link, time, json(action) + from items + where + source = ? + and active <> 0 + order by case when time = 0 then created else time end, id + `, source) +} + +func GetAllItemsForSource(db DB, source string) ([]Item, error) { + return getItems(db, ` + select + source, id, created, active, title, author, body, link, time, json(action) + from items + where + source = ? + order by case when time = 0 then created else time end, id + `, source) +} diff --git a/core/items_test.go b/core/items_test.go new file mode 100644 index 0000000..1ca07bc --- /dev/null +++ b/core/items_test.go @@ -0,0 +1,92 @@ +package core + +import ( + "fmt" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func AssertItemIs(t *testing.T, item Item, expected string) { + actual := fmt.Sprintf( + "%s/%s/%t/%s/%s/%s/%s/%d", + item.Source, + item.Id, + item.Active, + item.Title, + item.Author, + item.Body, + item.Link, + item.Time, + ) + if actual != expected { + t.Fatalf("expected %s, got %s", expected, actual) + } +} + +func TestAddItem(t *testing.T) { + db := EphemeralDb(t) + if err := AddSource(db, "test"); err != nil { + t.Fatalf("failed to add source: %v", err) + } + + if err := AddItems(db, []Item{ + {"test", "one", 0, true, "", "", "", "", 0, nil}, + {"test", "two", 0, true, "title", "author", "body", "link", 123456, nil}, + }); err != nil { + t.Fatalf("failed to add items: %v", err) + } + items, err := GetActiveItemsForSource(db, "test") + if err != nil { + t.Fatalf("failed to get active items: %v", err) + } + if len(items) != 2 { + t.Fatal("should get two items") + } + // order is by (time ?? created) so this ordering is correct as long as you don't run it in early 1970 + AssertItemIs(t, items[0], "test/two/true/title/author/body/link/123456") + AssertItemIs(t, items[1], "test/one/true/////0") + + if _, err = DeactivateItem(db, "test", "one"); err != nil { + t.Fatal(err) + } + items, err = GetActiveItemsForSource(db, "test") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatal("should get one item") + } + + items, err = GetAllItemsForSource(db, "test") + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatal("should get two items") + } + + deleted, err := DeleteItem(db, "test", "one") + if err != nil { + t.Fatal(err) + } + if deleted != 1 { + t.Fatal("expected one deletion") + } + + deleted, err = DeleteItem(db, "test", "one") + if err != nil { + t.Fatal(err) + } + if deleted != 0 { + t.Fatal("expected no deletion") + } + + items, err = GetAllItemsForSource(db, "test") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatal("should get one item") + } +}