From c336fcd82b6e53d738ee29183b55827aa09a6b3d Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Mon, 16 Aug 2021 01:56:56 +0100 Subject: [PATCH] feat: add database functions Add database functions to read and write statuses to the status bucket in a BoltDB database. Add database functions to read and write cards to the card bucket. Use encoding/gob to encode the Status and Card values before writing them to the database. --- .gitignore | 1 + README.md | 19 ++++- db.go | 217 ++++++++++++++++++++++++++++++++++++++++++++++++++ db_test.go | 201 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 5 ++ go.sum | 4 + kanban.go | 15 ++++ test/.gitkeep | 0 8 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 db.go create mode 100644 db_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 kanban.go create mode 100644 test/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5de6d17 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/test/*.db diff --git a/README.md b/README.md index 20fe5d3..ebe6a68 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ -# pelican +# Pelican -A simple Kanban board for the terminal \ No newline at end of file +## Summary + +Pelican is a simple Kanban board for your terminal. + +## Storage + +Data is stored in a [BoltDB](https://github.com/etcd-io/bbolt) database. +For Linux the default the database file is located at $XDG\_DATA\_HOME/pelican/pelican.db +If $XDG\_DATA\_HOME is not set then the default location is $HOME/.local/share/pelican/pelican.db by default. +For all other operating systems the default location is $HOME/.pelican/pelican.db. + +## Keybindings + +## Inspiration + +[The toukan project](https://github.com/witchard/toukan). diff --git a/db.go b/db.go new file mode 100644 index 0000000..1b777b4 --- /dev/null +++ b/db.go @@ -0,0 +1,217 @@ +package main + +import ( + "bytes" + "encoding/gob" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "time" + + bolt "go.etcd.io/bbolt" +) + +const ( + statusBucket string = "status" + cardBucket string = "card" +) + +// openDatabase opens the database, at a given path, for reading and writing. If the file does not exist it will be created. +// For linux, the default location of the database file is $XDG_DATA_HOME/pelican/pelican.db. If the XDG_DATA_HOME environment +// variable is not set then it will default to $HOME/.local/share/pelican/pelican.db. +// For all other operating systems the default location is $HOME/.pelican/pelican.db. +func openDatabase(path string) (*bolt.DB, error) { + opts := bolt.Options{ + Timeout: 1 * time.Second, + } + + if len(path) > 0 { + db, err := bolt.Open(path, 0600, &opts) + if err != nil { + return nil, fmt.Errorf("unable to open database at %s, %w", path, err) + } + + return db, nil + } + + dbFilename := "pelican.db" + + var dataDir string + + goos := runtime.GOOS + switch goos { + case "linux": + dataHome := os.Getenv("XDG_DATA_HOME") + + if len(dataHome) == 0 { + dataHome = filepath.Join(os.Getenv("HOME"), ".local", "share") + } + + dataDir = filepath.Join(dataHome, "pelican") + default: + dataDir = filepath.Join(os.Getenv("HOME"), ".pelican") + } + + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("unable to make directory %s, %w", dataDir, err) + } + + path = filepath.Join(dataDir, dbFilename) + + db, err := bolt.Open(path, 0600, &opts) + if err != nil { + return nil, fmt.Errorf("unable to open database at %s, %w", path, err) + } + + return db, nil +} + +// ensureBuckets ensures that the required buckets are created in the database. +func ensureBuckets(db *bolt.DB) error { + buckets := []string{statusBucket, cardBucket} + + return db.Update(func(tx *bolt.Tx) error { + for _, v := range buckets { + if _, err := tx.CreateBucketIfNotExists([]byte(v)); err != nil { + return fmt.Errorf("unable to ensure that %s bucket is created in the database, %w", v, err) + } + } + return nil + }) +} + +// saveStatuses saves one or more statuses to the status bucket. +func saveStatuses(db *bolt.DB, statuses []Status) error { + if len(statuses) == 0 { + return nil + } + + bucket := []byte(statusBucket) + + return db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucket) + + if b == nil { + return fmt.Errorf("bucket %s does not exist", bucket) + } + + for _, v := range statuses { + var err error + + if v.Id == 0 { + var id uint64 + if id, err = b.NextSequence(); err != nil { + return fmt.Errorf("unable to generate ID, %w", err) + } + v.Id = int(id) + } + + buf := new(bytes.Buffer) + encoder := gob.NewEncoder(buf) + if err = encoder.Encode(v); err != nil { + return fmt.Errorf("unable to encode data, %w", err) + } + + if err = b.Put([]byte(strconv.Itoa(v.Id)), buf.Bytes()); err != nil { + return fmt.Errorf("unable to add the status to the bucket, %w", err) + } + } + + return nil + }) +} + +// loadAllStatuses retrieves all the statuses from the status bucket. +func loadAllStatuses(db *bolt.DB) ([]Status, error) { + var statuses []Status + bucket := []byte(statusBucket) + + err := db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucket) + + if b == nil { + return fmt.Errorf("bucket %s does not exist", bucket) + } + + if err := b.ForEach(func(_, v []byte) error { + var s Status + buf := bytes.NewBuffer(v) + decoder := gob.NewDecoder(buf) + if err := decoder.Decode(&s); err != nil { + return fmt.Errorf("unable to decode data, %w", err) + } + + statuses = append(statuses, s) + + return nil + }); err != nil { + return err + } + return nil + }) + + return statuses, err +} + +// saveCard writes a card to the card bucket in BoltDB. +func saveCard(db *bolt.DB, card Card) (int, error) { + bucket := []byte(cardBucket) + + err := db.Update(func(tx *bolt.Tx) error { + var err error + b := tx.Bucket(bucket) + + if b == nil { + return fmt.Errorf("bucket %s does not exist", bucket) + } + + if card.Id == 0 { + var id uint64 + if id, err = b.NextSequence(); err != nil { + return fmt.Errorf("unable to generate an ID for the card, %w", err) + } + card.Id = int(id) + } + + buf := new(bytes.Buffer) + encoder := gob.NewEncoder(buf) + if err = encoder.Encode(card); err != nil { + return fmt.Errorf("unable to encode data, %w", err) + } + + if err = b.Put([]byte(strconv.Itoa(card.Id)), buf.Bytes()); err != nil { + return fmt.Errorf("unable to write the card to the bucket, %w", err) + } + + return nil + }) + + return card.Id, err +} + +// loadCard retrieves a card from the cards bucket in BoltDB. +func loadCard(db *bolt.DB, id int) (Card, error) { + var card Card + bucket := []byte(cardBucket) + + err := db.View(func(tx *bolt.Tx)error { + b := tx.Bucket(bucket) + + if b == nil { + return fmt.Errorf("bucket %s does not exist", bucket) + } + + buf := bytes.NewBuffer(b.Get([]byte(strconv.Itoa(id)))) + + decoder := gob.NewDecoder(buf) + if err := decoder.Decode(&card); err != nil { + return fmt.Errorf("unable to decode card data, %w", err) + } + + return nil + }) + + return card, err +} diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..cc28140 --- /dev/null +++ b/db_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + "os" + "reflect" + "testing" + + bolt "go.etcd.io/bbolt" +) + +func TestEnsureBuckets(t *testing.T) { + var db *bolt.DB + var err error + + testDB := "test/TestEnsureBuckets.db" + os.Remove(testDB) + + if db, err = openDatabase(testDB); err != nil { + t.Fatalf("An error occurred while opening the test database %s, %s.", testDB, err) + } + + defer db.Close() + + for i := 0; i < 3; i++ { + if err := ensureBuckets(db); err != nil { + t.Fatalf("An error occurred while executing `ensureBuckets`, %s.", err) + } + } + + expectedBuckets := []string{statusBucket, cardBucket} + + if err = db.View(func(tx *bolt.Tx) error { + for _, b := range expectedBuckets { + if bucket := tx.Bucket([]byte(b)); bucket == nil { + return fmt.Errorf("bucket %s does not exist in the database", b) + } + } + return nil + }); err != nil { + t.Fatalf("An error occurred whilst checking for the buckets, %s.", err) + } +} + +func TestReadAndWriteStatuses(t *testing.T) { + var db *bolt.DB + var err error + + testDB := "test/TestWriteAndReadStatuses.db" + os.Remove(testDB) + + if db, err = openDatabase(testDB); err != nil { + t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err) + } + + defer db.Close() + + if err = ensureBuckets(db); err != nil { + t.Fatalf("An error occurred whilst executing `ensureBuckets`, %s.", err) + } + + // Begin with an initial list of statuses + initialStatusList := []Status{ + { + Name: "Backlog", + CardIds: []int{1, 14, 9, 10}, + }, + { + Name: "Next", + CardIds: []int{2, 5, 12}, + }, + { + Name: "In progress", + CardIds: []int{3}, + }, + { + Name: "Finished!", + CardIds: []int{4, 6, 7, 8, 11, 13}, + }, + } + + // save the initial statuses to the board bucket. + if err = saveStatuses(db, initialStatusList); err != nil { + t.Fatalf("An error occurred whilst writing the initial status list to the database, %s", err) + } + + // load the status list from the database, modify it a little and write it to the database. + var modifiedStatusList []Status + if modifiedStatusList, err = loadAllStatuses(db); err != nil { + t.Fatalf("An error occurred whilst reading the initial status list to the database, %s", err) + } + + modifiedStatusList[2].CardIds = []int{3, 14} + + archiveStatus := Status{ + Name: "Archived", + CardIds: []int{34, 51, 894}, + } + + modifiedStatusList = append(modifiedStatusList, archiveStatus) + + if err := saveStatuses(db, modifiedStatusList); err != nil { + t.Fatalf("An error occurred whilst writing the modified status list to the database, %s", err) + } + + // read the final status list from the database and compare to the expected status list. + want := []Status{ + { + Id: 1, + Name: "Backlog", + CardIds: []int{1, 14, 9, 10}, + }, + { + Id: 2, + Name: "Next", + CardIds: []int{2, 5, 12}, + }, + { + Id: 3, + Name: "In progress", + CardIds: []int{3, 14}, + }, + { + Id: 4, + Name: "Finished!", + CardIds: []int{4, 6, 7, 8, 11, 13}, + }, + { + Id: 5, + Name: "Archived", + CardIds: []int{34, 51, 894}, + }, + } + + var got []Status + if got, err = loadAllStatuses(db); err != nil { + t.Fatalf("An error occurred whilst reading the modified status list from the database, %s", err) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Unexpected status list read from the database: got %+v, want %+v", got, want) + } else { + t.Logf("Expected status list read from the database: got %+v", got) + } +} + +func TestReadAndWriteCards(t *testing.T) { + var db *bolt.DB + var err error + + testDB := "test/TestReadWriteCards.db" + os.Remove(testDB) + + if db, err = openDatabase(testDB); err != nil { + t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err) + } + + if err = ensureBuckets(db); err != nil { + t.Fatalf("An error occurred whilst executing `ensureBuckets`, %s.", err) + } + + initialCard := Card{ + Title: "A test task.", + Content: "This task should be completed.", + } + + // Save the card + cardId, err := saveCard(db, initialCard) + if err != nil { + t.Fatalf("An error occurred whilst saving the initial card to the database, %s", err) + } + + modifiedCard, err := loadCard(db, cardId) + if err != nil { + t.Fatalf("An error occurred whilst loading the card from the database, %s", err) + } + + modifiedCard.Title = "Task: Foo bar baz." + + modifiedCardId, err := saveCard(db, modifiedCard) + if err != nil { + t.Fatalf("An error occurred whilst saving the modified card to the database, %s", err) + } + + want := Card{ + Id: 1, + Title: "Task: Foo bar baz.", + Content: "This task should be completed.", + } + + got, err := loadCard(db, modifiedCardId) + if err != nil { + t.Fatalf("An error occurred whilst loading the modified from the database, %s", err) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Unexpected card read from the database: got %+v, want %+v", got, want) + } else { + t.Logf("Expected card read from the database: got %+v", got) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b531f15 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitlab.com/dananglin/pelican + +go 1.16 + +require go.etcd.io/bbolt v1.3.6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3bc6bff --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d h1:L/IKR6COd7ubZrs2oTnTi73IhgqJ71c9s80WsQnh0Es= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/kanban.go b/kanban.go new file mode 100644 index 0000000..71e886a --- /dev/null +++ b/kanban.go @@ -0,0 +1,15 @@ +package main + +// Status represents the status of the Kanban board. +type Status struct { + Id int + Name string + CardIds []int +} + +// Card represents a card on a Kanban board +type Card struct { + Id int + Title string + Content string +} diff --git a/test/.gitkeep b/test/.gitkeep new file mode 100644 index 0000000..e69de29