feat: move a card between statuses #3
15 changed files with 719 additions and 624 deletions
83
card.go
83
card.go
|
@ -1,83 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Card represents a card on a Kanban board.
|
|
||||||
type Card struct {
|
|
||||||
ID int
|
|
||||||
Title string
|
|
||||||
Content string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: this function needs to be unit tested and documented.
|
|
||||||
func createCard(db *bolt.DB, title, content string) error {
|
|
||||||
// first ensure that there is a status list in the database
|
|
||||||
statusList, err := loadAllStatuses(db)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(statusList) == 0 {
|
|
||||||
return errors.New("the status list cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
card := Card{
|
|
||||||
ID: -1,
|
|
||||||
Title: title,
|
|
||||||
Content: content,
|
|
||||||
}
|
|
||||||
|
|
||||||
cardID, err := saveCard(db, card)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cardIDs := statusList[0].CardIds
|
|
||||||
|
|
||||||
cardIDs = append(cardIDs, cardID)
|
|
||||||
|
|
||||||
statusList[0].CardIds = cardIDs
|
|
||||||
|
|
||||||
// TODO: change the below to save a single status
|
|
||||||
if err := saveStatuses(db, statusList); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCard returns a Card value from the database.
|
|
||||||
func readCard(db *bolt.DB, id int) (Card, error) {
|
|
||||||
return loadCard(db, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: unit test and document this function.
|
|
||||||
func updateCard(db *bolt.DB, id int, title, content string) error {
|
|
||||||
card, err := loadCard(db, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
card.Title = title
|
|
||||||
card.Content = content
|
|
||||||
|
|
||||||
if _, err := saveCard(db, card); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: finish implementation
|
|
||||||
func moveCard(db *bolt.DB, fromStatusID, toStatusID int) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: finish implementation
|
|
||||||
func deleteCard(db *bolt.DB, id int) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
13
cmd/pelican/main.go
Normal file
13
cmd/pelican/main.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := ui.App(); err != nil {
|
||||||
|
fmt.Printf("Error: %s", err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,293 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDbCustomPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("An error occurred whilst getting the current directory, %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tempDataDir := filepath.Join(cwd, "test/temp_data_dir")
|
|
||||||
defer os.RemoveAll(tempDataDir)
|
|
||||||
|
|
||||||
path := filepath.Join(tempDataDir + "/test.db")
|
|
||||||
|
|
||||||
got, err := dbPath(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("An error occurred whilst executing `dbPath`, %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != path {
|
|
||||||
t.Errorf("Got unexpected database path: want %s, got %s", path, got)
|
|
||||||
} else {
|
|
||||||
t.Logf("Got expected database path: got %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDbDefaultPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("An error occurred whilst getting the current directory, %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
testXdgDataHome := fmt.Sprintf("%s/%s/%s/%s", cwd, "test", ".local", "data")
|
|
||||||
defer os.RemoveAll(testXdgDataHome)
|
|
||||||
|
|
||||||
if err := os.Setenv("XDG_DATA_HOME", testXdgDataHome); err != nil {
|
|
||||||
t.Fatalf("An error occurred whilst setting the XDG_DATA_HOME environment variable, %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var want string
|
|
||||||
|
|
||||||
goos := runtime.GOOS
|
|
||||||
switch goos {
|
|
||||||
case "linux":
|
|
||||||
want = filepath.Join(os.Getenv("XDG_DATA_HOME"), "pelican", "pelican.db")
|
|
||||||
default:
|
|
||||||
want = filepath.Join(os.Getenv("HOME"), ".pelican", "pelican.db")
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := dbPath("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("An error occurred whilst executing `dbPath`, %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("Got unexpected database path: want %s, got %s", want, got)
|
|
||||||
} else {
|
|
||||||
t.Logf("Got expected database path: got %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureBuckets(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var db *bolt.DB
|
|
||||||
|
|
||||||
var err error
|
|
||||||
|
|
||||||
testDB := "test/databases/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 bucketNotExistError{bucket: b}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("An error occurred whilst checking for the buckets, %s.", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadAndWriteStatuses(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var db *bolt.DB
|
|
||||||
|
|
||||||
var err error
|
|
||||||
|
|
||||||
testDB := "test/databases/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{
|
|
||||||
{
|
|
||||||
ID: -1,
|
|
||||||
Name: "Backlog",
|
|
||||||
CardIds: []int{1, 14, 9, 10},
|
|
||||||
Order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: -1,
|
|
||||||
Name: "Next",
|
|
||||||
CardIds: []int{2, 5, 12},
|
|
||||||
Order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: -1,
|
|
||||||
Name: "In progress",
|
|
||||||
CardIds: []int{3},
|
|
||||||
Order: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: -1,
|
|
||||||
Name: "Finished!",
|
|
||||||
CardIds: []int{4, 6, 7, 8, 11, 13},
|
|
||||||
Order: 4,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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{
|
|
||||||
ID: -1,
|
|
||||||
Name: "Archived",
|
|
||||||
CardIds: []int{34, 51, 894},
|
|
||||||
Order: 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
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},
|
|
||||||
Order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Next",
|
|
||||||
CardIds: []int{2, 5, 12},
|
|
||||||
Order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 3,
|
|
||||||
Name: "In progress",
|
|
||||||
CardIds: []int{3, 14},
|
|
||||||
Order: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 4,
|
|
||||||
Name: "Finished!",
|
|
||||||
CardIds: []int{4, 6, 7, 8, 11, 13},
|
|
||||||
Order: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 5,
|
|
||||||
Name: "Archived",
|
|
||||||
CardIds: []int{34, 51, 894},
|
|
||||||
Order: 5,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var db *bolt.DB
|
|
||||||
|
|
||||||
var err error
|
|
||||||
|
|
||||||
testDB := "test/databases/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{
|
|
||||||
ID: -1,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
140
internal/board/board.go
Normal file
140
internal/board/board.go
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
package board
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card"
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/database"
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/status"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadBoard reads the board from the database. If no board exists then a new one will be created.
|
||||||
|
func LoadBoard(path string) (*bolt.DB, error) {
|
||||||
|
db, err := database.OpenDatabase(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to open the database, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusList, err := ReadStatusList(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(statusList) == 0 {
|
||||||
|
newStatusList := status.NewDefaultStatusList()
|
||||||
|
if err := database.WriteStatusList(db, newStatusList); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to save the default status list to the database, %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadStatusList returns an ordered list of statuses from the database.
|
||||||
|
func ReadStatusList(db *bolt.DB) ([]status.Status, error) {
|
||||||
|
statuses, err := database.ReadStatusList(db)
|
||||||
|
if err != nil {
|
||||||
|
return statuses, fmt.Errorf("unable to read the status list, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(status.ByStatusOrder(statuses))
|
||||||
|
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Finish implementation.
|
||||||
|
func ReadStatus(db *bolt.DB) (status.Status, error) {
|
||||||
|
return status.Status{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Finish implementation.
|
||||||
|
func CreateStatus(db *bolt.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Finish implementation.
|
||||||
|
func UpdateStatus(db *bolt.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Finish implementation.
|
||||||
|
func DeleteStatus(db *bolt.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCard creates a card in the database.
|
||||||
|
func CreateCard(db *bolt.DB, title, content string) error {
|
||||||
|
statusList, err := database.ReadStatusList(db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to read the status list, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(statusList) == 0 {
|
||||||
|
return statusListEmptyError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
card := card.Card{
|
||||||
|
ID: -1,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
cardID, err := database.WriteCard(db, card)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to write card to the database, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cardIDs := statusList[0].CardIds
|
||||||
|
|
||||||
|
cardIDs = append(cardIDs, cardID)
|
||||||
|
|
||||||
|
statusList[0].CardIds = cardIDs
|
||||||
|
|
||||||
|
// TODO: change the below to save a single status
|
||||||
|
if err := database.WriteStatusList(db, statusList); err != nil {
|
||||||
|
return fmt.Errorf("unable to write status list to the database, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCard returns a Card value from the database.
|
||||||
|
func ReadCard(db *bolt.DB, id int) (card.Card, error) {
|
||||||
|
c, err := database.ReadCard(db, id)
|
||||||
|
if err != nil {
|
||||||
|
return card.Card{}, fmt.Errorf("unable to read card [%d] from the database, %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCard modifies an existing card and saves the modification to the database.
|
||||||
|
func UpdateCard(db *bolt.DB, id int, title, content string) error {
|
||||||
|
c, err := ReadCard(db, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Title = title
|
||||||
|
c.Content = content
|
||||||
|
|
||||||
|
if _, err := database.WriteCard(db, c); err != nil {
|
||||||
|
return fmt.Errorf("unable to write card to the database, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveCard moves a card between statuses.
|
||||||
|
// TODO: finish implementation.
|
||||||
|
func MoveCard(db *bolt.DB, fromStatusID, toStatusID int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCard deletes a card from the database.
|
||||||
|
// TODO: finish implementation.
|
||||||
|
func DeleteCard(db *bolt.DB, id int) error {
|
||||||
|
return nil
|
||||||
|
}
|
130
internal/board/board_test.go
Normal file
130
internal/board/board_test.go
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
package board_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/board"
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCardLifecycle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
projectDir, err := projectRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
testDBPath := filepath.Join(projectDir, "test", "databases", "Board_TestCardLifecycle.db")
|
||||||
|
os.Remove(testDBPath)
|
||||||
|
|
||||||
|
db, err := board.LoadBoard(testDBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to open the test database %s, %s.", testDBPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = db.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
cardTitle := "A test card."
|
||||||
|
cardContent := "Ensure that this card is safely stored in the database."
|
||||||
|
expectedCardID := 1
|
||||||
|
|
||||||
|
testCreateCard(t, db, cardTitle, cardContent, expectedCardID)
|
||||||
|
testReadCard(t, db, expectedCardID, cardTitle, cardContent)
|
||||||
|
|
||||||
|
newCardTitle := "Test card updated."
|
||||||
|
newCardContent := "Ensure that this card is safely updated in the database."
|
||||||
|
|
||||||
|
testUpdateCard(t, db, expectedCardID, newCardTitle, newCardContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateCard(t *testing.T, db *bolt.DB, title, content string, wantID int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := board.CreateCard(db, title, content); err != nil {
|
||||||
|
t.Fatalf("Unable to create the test card, %s.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusList, err := board.ReadStatusList(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to run `ReadStatusList`, %s.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(statusList) == 0 {
|
||||||
|
t.Fatal("The status list appears to be empty.")
|
||||||
|
}
|
||||||
|
|
||||||
|
cardIDs := statusList[0].CardIds
|
||||||
|
|
||||||
|
if len(cardIDs) != 1 {
|
||||||
|
t.Fatalf("Unexpected number of cards in the default status, want: %d, got %d.", 1, len(cardIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotID := cardIDs[0]; wantID != gotID {
|
||||||
|
t.Errorf("Unexpected card ID found in the default status, want: %d, got %d.", wantID, gotID)
|
||||||
|
} else {
|
||||||
|
t.Logf("Expected card ID found in the default status, got %d.", gotID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReadCard(t *testing.T, db *bolt.DB, cardID int, wantTitle, wantContent string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
card, err := board.ReadCard(db, cardID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to read test card, %s.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if card.Title != wantTitle {
|
||||||
|
t.Errorf("Unexpected card title received, want: %s, got: %s.", wantTitle, card.Title)
|
||||||
|
} else {
|
||||||
|
t.Logf("Expected card title received, got: %s.", card.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if card.Content != wantContent {
|
||||||
|
t.Errorf("Unexpected card content received, want: %s, got: %s.", wantContent, card.Content)
|
||||||
|
} else {
|
||||||
|
t.Logf("Expected card title received, got: %s.", card.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateCard(t *testing.T, db *bolt.DB, cardID int, newTitle, newContent string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := board.UpdateCard(db, cardID, newTitle, newContent); err != nil {
|
||||||
|
t.Fatalf("Unable to update the test card, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := board.ReadCard(db, cardID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to read the modified test card, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := card.Card{
|
||||||
|
ID: cardID,
|
||||||
|
Title: newTitle,
|
||||||
|
Content: newContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Unexpected card read from the database: want %+v, got %+v", want, got)
|
||||||
|
} else {
|
||||||
|
t.Logf("Expected card read from the database: got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectRoot() (string, error) {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to get the current working directory, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(cwd, "..", ".."), nil
|
||||||
|
}
|
7
internal/board/errors.go
Normal file
7
internal/board/errors.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package board
|
||||||
|
|
||||||
|
type statusListEmptyError struct{}
|
||||||
|
|
||||||
|
func (e statusListEmptyError) Error() string {
|
||||||
|
return "the status list must not be empty"
|
||||||
|
}
|
8
internal/card/card.go
Normal file
8
internal/card/card.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package card
|
||||||
|
|
||||||
|
// Card represents a card on a Kanban board.
|
||||||
|
type Card struct {
|
||||||
|
ID int
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package main
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
@ -10,6 +10,8 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card"
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/status"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,91 +20,36 @@ const (
|
||||||
cardBucket string = "card"
|
cardBucket string = "card"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mkDataDir(dir string) error {
|
// OpenDatabase opens the database, at a given path, for reading and writing.
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
// If the file does not exist it will be created.
|
||||||
return fmt.Errorf("error while making directory %s, %w", dir, err)
|
func OpenDatabase(path string) (*bolt.DB, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
path = dbPath(path)
|
||||||
|
|
||||||
|
if err = mkDataDir(path); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to make the data directory, %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dbPath returns the path to the database file. If a path is given then that is returned. Otherwise the default path is returned.
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// Before returning the path, dbPath attempts to create the directory of the database file. An error is returned if there
|
|
||||||
// are any issues creating this directory.
|
|
||||||
func dbPath(path string) (string, error) {
|
|
||||||
if len(path) > 0 {
|
|
||||||
filepath.Dir(path)
|
|
||||||
|
|
||||||
return path, mkDataDir(filepath.Dir(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
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, 0o700); err != nil {
|
|
||||||
return "", fmt.Errorf("unable to make directory %s, %w", dataDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path = filepath.Join(dataDir, dbFilename)
|
|
||||||
|
|
||||||
return path, mkDataDir(dataDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// openDatabase opens the database, at a given path, for reading and writing. If the file does not exist it will be created.
|
|
||||||
func openDatabase(path string) (*bolt.DB, error) {
|
|
||||||
opts := bolt.Options{
|
opts := bolt.Options{
|
||||||
Timeout: 1 * time.Second,
|
Timeout: 1 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := bolt.Open(path, 0o600, &opts)
|
var db *bolt.DB
|
||||||
if err != nil {
|
|
||||||
|
if db, err = bolt.Open(path, 0o600, &opts); err != nil {
|
||||||
return nil, fmt.Errorf("unable to open database at %s, %w", path, err)
|
return nil, fmt.Errorf("unable to open database at %s, %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = ensureBuckets(db); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to ensure the required buckets are in the database, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureBuckets ensures that the required buckets are created in the database.
|
// WriteStatusList saves one or more statuses to the status bucket.
|
||||||
func ensureBuckets(db *bolt.DB) error {
|
func WriteStatusList(db *bolt.DB, statuses []status.Status) error {
|
||||||
buckets := []string{statusBucket, cardBucket}
|
|
||||||
|
|
||||||
err := 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
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error while ensuring buckets exist in the database, %w", 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 {
|
if len(statuses) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -147,9 +94,9 @@ func saveStatuses(db *bolt.DB, statuses []Status) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadAllStatuses retrieves all the statuses from the status bucket.
|
// ReadStatusList retrieves all the statuses from the status bucket.
|
||||||
func loadAllStatuses(db *bolt.DB) ([]Status, error) {
|
func ReadStatusList(db *bolt.DB) ([]status.Status, error) {
|
||||||
var statuses []Status
|
var statuses []status.Status
|
||||||
|
|
||||||
bucket := []byte(statusBucket)
|
bucket := []byte(statusBucket)
|
||||||
|
|
||||||
|
@ -161,7 +108,7 @@ func loadAllStatuses(db *bolt.DB) ([]Status, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := b.ForEach(func(_, v []byte) error {
|
if err := b.ForEach(func(_, v []byte) error {
|
||||||
var s Status
|
var s status.Status
|
||||||
buf := bytes.NewBuffer(v)
|
buf := bytes.NewBuffer(v)
|
||||||
decoder := gob.NewDecoder(buf)
|
decoder := gob.NewDecoder(buf)
|
||||||
if err := decoder.Decode(&s); err != nil {
|
if err := decoder.Decode(&s); err != nil {
|
||||||
|
@ -184,8 +131,8 @@ func loadAllStatuses(db *bolt.DB) ([]Status, error) {
|
||||||
return statuses, nil
|
return statuses, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveCard writes a card to the card bucket in BoltDB.
|
// WriteCard creates or updates a card to the card bucket in BoltDB.
|
||||||
func saveCard(db *bolt.DB, card Card) (int, error) {
|
func WriteCard(db *bolt.DB, card card.Card) (int, error) {
|
||||||
bucket := []byte(cardBucket)
|
bucket := []byte(cardBucket)
|
||||||
|
|
||||||
err := db.Update(func(tx *bolt.Tx) error {
|
err := db.Update(func(tx *bolt.Tx) error {
|
||||||
|
@ -223,9 +170,9 @@ func saveCard(db *bolt.DB, card Card) (int, error) {
|
||||||
return card.ID, nil
|
return card.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCard retrieves a card from the cards bucket in BoltDB.
|
// ReadCard reads a card from the cards bucket in BoltDB.
|
||||||
func loadCard(db *bolt.DB, id int) (Card, error) {
|
func ReadCard(db *bolt.DB, id int) (card.Card, error) {
|
||||||
var card Card
|
var card card.Card
|
||||||
|
|
||||||
bucket := []byte(cardBucket)
|
bucket := []byte(cardBucket)
|
||||||
|
|
||||||
|
@ -251,3 +198,68 @@ func loadCard(db *bolt.DB, id int) (Card, error) {
|
||||||
|
|
||||||
return card, nil
|
return card, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbPath returns the path to the database file. If a path is given then that is returned. Otherwise the default path is returned.
|
||||||
|
// 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 dbPath(path string) string {
|
||||||
|
if len(path) > 0 {
|
||||||
|
filepath.Dir(path)
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
path = filepath.Join(dataDir, dbFilename)
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// mkDataDir creates the data directory of a given path to the database.
|
||||||
|
func mkDataDir(path string) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("error while making directory %s, %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureBuckets ensures that the required buckets are created in the database.
|
||||||
|
func ensureBuckets(db *bolt.DB) error {
|
||||||
|
buckets := []string{statusBucket, cardBucket}
|
||||||
|
|
||||||
|
err := 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
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error while ensuring buckets exist in the database, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
227
internal/database/database_test.go
Normal file
227
internal/database/database_test.go
Normal file
|
@ -0,0 +1,227 @@
|
||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card"
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/database"
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/status"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenDataBaseXDGDataDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
projectDir, err := projectRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
testXdgDataHome := filepath.Join(projectDir, "test", "databases", "xdg_data_dir")
|
||||||
|
defer os.RemoveAll(testXdgDataHome)
|
||||||
|
|
||||||
|
if err := os.Setenv("XDG_DATA_HOME", testXdgDataHome); err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst setting the XDG_DATA_HOME environment variable, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = os.Unsetenv("XDG_DATA_HOME")
|
||||||
|
}()
|
||||||
|
|
||||||
|
db, err := database.OpenDatabase("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst opening the test database, %s.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
wantDB := filepath.Join(testXdgDataHome, "pelican", "pelican.db")
|
||||||
|
|
||||||
|
// ensure that the database file exists
|
||||||
|
_, err = os.Stat(wantDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to get file information of the test database, %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAndReadStatusList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var db *bolt.DB
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
projectDir, err := projectRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
testDB := filepath.Join(projectDir, "test", "databases", "Database_TestWriteAndReadStatusList.db")
|
||||||
|
os.Remove(testDB)
|
||||||
|
|
||||||
|
if db, err = database.OpenDatabase(testDB); err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = db.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
testWriteStatusList(t, db)
|
||||||
|
testReadStatusList(t, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWriteStatusList(t *testing.T, db *bolt.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
newStatusList := []status.Status{
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Name: "Backlog",
|
||||||
|
CardIds: []int{1, 14, 9, 10},
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Name: "Next",
|
||||||
|
CardIds: []int{2, 5, 12},
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Name: "In progress",
|
||||||
|
CardIds: []int{3, 14},
|
||||||
|
Order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Name: "Finished!",
|
||||||
|
CardIds: []int{4, 6, 7, 8, 11, 13},
|
||||||
|
Order: 4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.WriteStatusList(db, newStatusList); err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst writing the initial status list to the database, %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReadStatusList(t *testing.T, db *bolt.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, err := database.ReadStatusList(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst reading the modified status list from the database, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []status.Status{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Backlog",
|
||||||
|
CardIds: []int{1, 14, 9, 10},
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Next",
|
||||||
|
CardIds: []int{2, 5, 12},
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Name: "In progress",
|
||||||
|
CardIds: []int{3, 14},
|
||||||
|
Order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Name: "Finished!",
|
||||||
|
CardIds: []int{4, 6, 7, 8, 11, 13},
|
||||||
|
Order: 4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestReadAndWriteCard(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var db *bolt.DB
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
projectDir, err := projectRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
testDB := filepath.Join(projectDir, "test", "databases", "Database_TestReadWriteCard.db")
|
||||||
|
os.Remove(testDB)
|
||||||
|
|
||||||
|
if db, err = database.OpenDatabase(testDB); err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = db.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
cardID := testWriteCard(t, db)
|
||||||
|
testReadCard(t, db, cardID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWriteCard(t *testing.T, db *bolt.DB) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
newCard := card.Card{
|
||||||
|
ID: -1,
|
||||||
|
Title: "A test task.",
|
||||||
|
Content: "This task should be completed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
cardID, err := database.WriteCard(db, newCard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst writing the card to the database, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cardID
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReadCard(t *testing.T, db *bolt.DB, cardID int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, err := database.ReadCard(db, cardID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst loading the modified from the database, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := card.Card{
|
||||||
|
ID: 1,
|
||||||
|
Title: "A test task.",
|
||||||
|
Content: "This task should be completed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectRoot() (string, error) {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to get the current working directory, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(cwd, "..", ".."), nil
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package main
|
package database
|
||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
45
internal/status/status.go
Normal file
45
internal/status/status.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package status
|
||||||
|
|
||||||
|
// Status represents the status of the Kanban board.
|
||||||
|
type Status struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
CardIds []int
|
||||||
|
Order int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByStatusOrder implements sort.Interface for []Status based on the Order field.
|
||||||
|
type ByStatusOrder []Status
|
||||||
|
|
||||||
|
func (s ByStatusOrder) Len() int {
|
||||||
|
return len(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ByStatusOrder) Swap(i, j int) {
|
||||||
|
s[i], s[j] = s[j], s[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ByStatusOrder) Less(i, j int) bool {
|
||||||
|
return s[i].Order < s[j].Order
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDefaultStatusList creates the default list of statuses and saves the statuses to the database.
|
||||||
|
func NewDefaultStatusList() []Status {
|
||||||
|
return []Status{
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Name: "To Do",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Name: "Doing",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Name: "Done",
|
||||||
|
Order: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,19 @@
|
||||||
// Right now this will be a very simple, scuffed interface.
|
// Right now this will be a very simple, scuffed interface.
|
||||||
package main
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
"forge.dananglin.me.uk/code/dananglin/pelican/internal/board"
|
||||||
"github.com/eiannone/keyboard"
|
"github.com/eiannone/keyboard"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func badUI(db *bolt.DB) error {
|
func App() error {
|
||||||
|
var db *bolt.DB
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
keysEvents, err := keyboard.GetKeys(10)
|
keysEvents, err := keyboard.GetKeys(10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to create the keysEvent channel, %w", err)
|
return fmt.Errorf("unable to create the keysEvent channel, %w", err)
|
||||||
|
@ -18,10 +23,9 @@ func badUI(db *bolt.DB) error {
|
||||||
_ = keyboard.Close()
|
_ = keyboard.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// infinite loop
|
fmt.Println(usage())
|
||||||
fmt.Println(uiUsage())
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
for {
|
for {
|
||||||
event := <-keysEvents
|
event := <-keysEvents
|
||||||
if event.Err != nil {
|
if event.Err != nil {
|
||||||
|
@ -32,51 +36,57 @@ func badUI(db *bolt.DB) error {
|
||||||
case 'q':
|
case 'q':
|
||||||
break app
|
break app
|
||||||
case 'r':
|
case 'r':
|
||||||
if err := uiRefreshScuffedBoard(db); err != nil {
|
if err = refresh(db); err != nil {
|
||||||
fmt.Printf("Error: Unable to refresh board, %s\n", err)
|
fmt.Printf("Error: Unable to refresh board, %s\n", err)
|
||||||
}
|
}
|
||||||
case 'a':
|
case 'a':
|
||||||
if err := uiCreateCard(db); err != nil {
|
if err = newCard(db); err != nil {
|
||||||
fmt.Printf("Error: Unable to add a card, %s\n", err)
|
fmt.Printf("Error: Unable to add a card, %s\n", err)
|
||||||
}
|
}
|
||||||
case 'v':
|
case 'v':
|
||||||
if err := uiViewCard(db, 1); err != nil {
|
if err = viewCard(db, 1); err != nil {
|
||||||
fmt.Printf("Error: Unable to view card, %s\n", err)
|
fmt.Printf("Error: Unable to view card, %s\n", err)
|
||||||
}
|
}
|
||||||
|
case 'o':
|
||||||
|
// TODO: How do we close the db?
|
||||||
|
db, err = openProject("")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: Unable to open the project, %s\n", err)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
fmt.Println("Error: Unknown key event.")
|
fmt.Println("Error: Unknown key event.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for a keyboard press
|
db.Close()
|
||||||
// take action
|
|
||||||
// a to add a card
|
|
||||||
// u to update a card
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiRefreshScuffedBoard(db *bolt.DB) error {
|
func refresh(db *bolt.DB) error {
|
||||||
statusList, err := readStatuses(db)
|
statusList, err := board.ReadStatusList(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("unable to get the status list, %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("--------------------\n")
|
fmt.Printf("--------------------\n")
|
||||||
for _, s := range statusList{
|
|
||||||
|
for _, s := range statusList {
|
||||||
fmt.Printf("Status ID: %d\nStatus Name: \"%s\"\nCard IDs: %v\n\n", s.ID, s.Name, s.CardIds)
|
fmt.Printf("Status ID: %d\nStatus Name: \"%s\"\nCard IDs: %v\n\n", s.ID, s.Name, s.CardIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("--------------------\n\n\n")
|
fmt.Printf("--------------------\n\n\n")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiCreateCard(db *bolt.DB) error {
|
func newCard(db *bolt.DB) error {
|
||||||
title := "A card title"
|
title := "A card title"
|
||||||
|
|
||||||
content := "As a user, this is a ticket for me.\nAs a user, I want to close it."
|
content := "As a user, this is a ticket for me.\nAs a user, I want to close it."
|
||||||
|
|
||||||
if err := createCard(db, title, content); err != nil {
|
if err := board.CreateCard(db, title, content); err != nil {
|
||||||
return err
|
return fmt.Errorf("unable to create card, %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Sample card created successfully.")
|
fmt.Println("Sample card created successfully.")
|
||||||
|
@ -84,13 +94,12 @@ func uiCreateCard(db *bolt.DB) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiViewCard(db *bolt.DB, id int) error {
|
func viewCard(db *bolt.DB, id int) error {
|
||||||
card, err := readCard(db, id)
|
card, err := board.ReadCard(db, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("unable to read card, %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fmt.Printf("====================\n")
|
fmt.Printf("====================\n")
|
||||||
fmt.Printf("[%d] %s\n", card.ID, card.Title)
|
fmt.Printf("[%d] %s\n", card.ID, card.Title)
|
||||||
fmt.Printf("--------------------\n")
|
fmt.Printf("--------------------\n")
|
||||||
|
@ -100,14 +109,28 @@ func uiViewCard(db *bolt.DB, id int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiUsage() string {
|
func openProject(path string) (*bolt.DB, error) {
|
||||||
|
db, err := board.LoadBoard(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to load board, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = refresh(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() string {
|
||||||
usage := `
|
usage := `
|
||||||
Press 'q' to quit
|
Press 'o' to open the project
|
||||||
Press 'r' to refresh the board
|
Press 'r' to refresh the board
|
||||||
Press 'a' to add a sample card
|
Press 'a' to add a sample card
|
||||||
Press 'v' to view the first sample card
|
Press 'v' to view the first sample card
|
||||||
|
Press 'q' to quit
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
return usage
|
return usage
|
||||||
}
|
}
|
|
@ -22,7 +22,7 @@ var Default = Build
|
||||||
func Test() error {
|
func Test() error {
|
||||||
goTest := sh.RunCmd("go", "test")
|
goTest := sh.RunCmd("go", "test")
|
||||||
|
|
||||||
args := []string{"."}
|
args := []string{"./..."}
|
||||||
|
|
||||||
if os.Getenv("PELICAN_TEST_VERBOSE") == "1" {
|
if os.Getenv("PELICAN_TEST_VERBOSE") == "1" {
|
||||||
args = append(args, "-v")
|
args = append(args, "-v")
|
||||||
|
@ -42,7 +42,8 @@ func Lint() error {
|
||||||
|
|
||||||
// Build build the executable
|
// Build build the executable
|
||||||
func Build() error {
|
func Build() error {
|
||||||
return sh.Run("go", "build", "-o", binary, "main.go")
|
main := "./cmd/pelican/main.go"
|
||||||
|
return sh.Run("go", "build", "-o", binary, main)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean clean the workspace
|
// Clean clean the workspace
|
||||||
|
|
53
main.go
53
main.go
|
@ -1,53 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
path, err := dbPath("")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: Unable to get the database path, %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := openDatabase(path)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: Unable to open the database, %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
if err := ensureBuckets(db); err != nil {
|
|
||||||
fmt.Printf("Error: Unable to ensure buckets exist, %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusList []Status
|
|
||||||
|
|
||||||
statusList, err = readStatuses(db)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: Unable to get status list, %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(statusList) == 0 {
|
|
||||||
newStatusList := newDefaultStatusList()
|
|
||||||
if err := saveStatuses(db, newStatusList); err != nil {
|
|
||||||
fmt.Printf("Error: Unable to save the default status list to the database, %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = readStatuses(db)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: Unable to get status list, %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := badUI(db); err != nil {
|
|
||||||
fmt.Printf("Error: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
82
status.go
82
status.go
|
@ -1,82 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Status represents the status of the Kanban board.
|
|
||||||
type Status struct {
|
|
||||||
ID int
|
|
||||||
Name string
|
|
||||||
CardIds []int
|
|
||||||
Order int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ByStatusOrder implements sort.Interface for []Status based on the Order field.
|
|
||||||
type ByStatusOrder []Status
|
|
||||||
|
|
||||||
func (s ByStatusOrder) Len() int {
|
|
||||||
return len(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ByStatusOrder) Swap(i, j int) {
|
|
||||||
s[i], s[j] = s[j], s[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s ByStatusOrder) Less(i, j int) bool {
|
|
||||||
return s[i].Order < s[j].Order
|
|
||||||
}
|
|
||||||
|
|
||||||
// readStatuses returns an ordered list of statuses from the database.
|
|
||||||
// TODO: function needs to be unit tested.
|
|
||||||
func readStatuses(db *bolt.DB) ([]Status, error) {
|
|
||||||
statuses, err := loadAllStatuses(db)
|
|
||||||
if err != nil {
|
|
||||||
return statuses, fmt.Errorf("unable to read statuses, %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Sort(ByStatusOrder(statuses))
|
|
||||||
|
|
||||||
return statuses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readStatus(db *bolt.DB) (Status, error) {
|
|
||||||
return Status{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createStatus(db *bolt.DB) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateStatus(db *bolt.DB) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteStatus(db *bolt.DB) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newDefaultStatusList creates the default list of statuses and saves the statuses to the database.
|
|
||||||
// TODO: function needs to be unit tested.
|
|
||||||
func newDefaultStatusList() []Status {
|
|
||||||
return []Status{
|
|
||||||
{
|
|
||||||
ID: -1,
|
|
||||||
Name: "To Do",
|
|
||||||
Order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: -1,
|
|
||||||
Name: "Doing",
|
|
||||||
Order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: -1,
|
|
||||||
Name: "Done",
|
|
||||||
Order: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue