feat: move a card between statuses #3
8 changed files with 460 additions and 2 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/test/*.db
|
19
README.md
19
README.md
|
@ -1,3 +1,18 @@
|
||||||
# pelican
|
# Pelican
|
||||||
|
|
||||||
A simple Kanban board for the terminal
|
## 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).
|
||||||
|
|
217
db.go
Normal file
217
db.go
Normal file
|
@ -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
|
||||||
|
}
|
201
db_test.go
Normal file
201
db_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
5
go.mod
Normal file
5
go.mod
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module gitlab.com/dananglin/pelican
|
||||||
|
|
||||||
|
go 1.16
|
||||||
|
|
||||||
|
require go.etcd.io/bbolt v1.3.6
|
4
go.sum
Normal file
4
go.sum
Normal file
|
@ -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=
|
15
kanban.go
Normal file
15
kanban.go
Normal file
|
@ -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
|
||||||
|
}
|
0
test/.gitkeep
Normal file
0
test/.gitkeep
Normal file
Loading…
Reference in a new issue