diff --git a/card.go b/card.go deleted file mode 100644 index 7cbff20..0000000 --- a/card.go +++ /dev/null @@ -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 -} diff --git a/cmd/pelican/main.go b/cmd/pelican/main.go new file mode 100644 index 0000000..e8ba2fa --- /dev/null +++ b/cmd/pelican/main.go @@ -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) + } +} diff --git a/db_internal_test.go b/db_internal_test.go deleted file mode 100644 index 4d217ed..0000000 --- a/db_internal_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/board/board.go b/internal/board/board.go new file mode 100644 index 0000000..473f3a1 --- /dev/null +++ b/internal/board/board.go @@ -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 +} diff --git a/internal/board/board_test.go b/internal/board/board_test.go new file mode 100644 index 0000000..f4b61ba --- /dev/null +++ b/internal/board/board_test.go @@ -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 +} diff --git a/internal/board/errors.go b/internal/board/errors.go new file mode 100644 index 0000000..4410340 --- /dev/null +++ b/internal/board/errors.go @@ -0,0 +1,7 @@ +package board + +type statusListEmptyError struct{} + +func (e statusListEmptyError) Error() string { + return "the status list must not be empty" +} diff --git a/internal/card/card.go b/internal/card/card.go new file mode 100644 index 0000000..3dbdd53 --- /dev/null +++ b/internal/card/card.go @@ -0,0 +1,8 @@ +package card + +// Card represents a card on a Kanban board. +type Card struct { + ID int + Title string + Content string +} diff --git a/db.go b/internal/database/database.go similarity index 76% rename from db.go rename to internal/database/database.go index 53c4e10..7842e96 100644 --- a/db.go +++ b/internal/database/database.go @@ -1,4 +1,4 @@ -package main +package database import ( "bytes" @@ -10,6 +10,8 @@ import ( "strconv" "time" + "forge.dananglin.me.uk/code/dananglin/pelican/internal/card" + "forge.dananglin.me.uk/code/dananglin/pelican/internal/status" bolt "go.etcd.io/bbolt" ) @@ -18,91 +20,36 @@ const ( cardBucket string = "card" ) -func mkDataDir(dir string) error { - if err := os.MkdirAll(dir, 0o700); err != nil { - return fmt.Errorf("error while making directory %s, %w", dir, err) +// 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) { + 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{ Timeout: 1 * time.Second, } - db, err := bolt.Open(path, 0o600, &opts) - if err != nil { + var db *bolt.DB + + if db, err = bolt.Open(path, 0o600, &opts); err != nil { 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 } -// 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 -} - -// saveStatuses saves one or more statuses to the status bucket. -func saveStatuses(db *bolt.DB, statuses []Status) error { +// WriteStatusList saves one or more statuses to the status bucket. +func WriteStatusList(db *bolt.DB, statuses []status.Status) error { if len(statuses) == 0 { return nil } @@ -147,9 +94,9 @@ func saveStatuses(db *bolt.DB, statuses []Status) error { return nil } -// loadAllStatuses retrieves all the statuses from the status bucket. -func loadAllStatuses(db *bolt.DB) ([]Status, error) { - var statuses []Status +// ReadStatusList retrieves all the statuses from the status bucket. +func ReadStatusList(db *bolt.DB) ([]status.Status, error) { + var statuses []status.Status bucket := []byte(statusBucket) @@ -161,7 +108,7 @@ func loadAllStatuses(db *bolt.DB) ([]Status, error) { } if err := b.ForEach(func(_, v []byte) error { - var s Status + var s status.Status buf := bytes.NewBuffer(v) decoder := gob.NewDecoder(buf) if err := decoder.Decode(&s); err != nil { @@ -184,8 +131,8 @@ func loadAllStatuses(db *bolt.DB) ([]Status, error) { return statuses, nil } -// saveCard writes a card to the card bucket in BoltDB. -func saveCard(db *bolt.DB, card Card) (int, error) { +// WriteCard creates or updates a card to the card bucket in BoltDB. +func WriteCard(db *bolt.DB, card card.Card) (int, error) { bucket := []byte(cardBucket) 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 } -// loadCard retrieves a card from the cards bucket in BoltDB. -func loadCard(db *bolt.DB, id int) (Card, error) { - var card Card +// ReadCard reads a card from the cards bucket in BoltDB. +func ReadCard(db *bolt.DB, id int) (card.Card, error) { + var card card.Card bucket := []byte(cardBucket) @@ -251,3 +198,68 @@ func loadCard(db *bolt.DB, id int) (Card, error) { 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 +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..f16afce --- /dev/null +++ b/internal/database/database_test.go @@ -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 +} diff --git a/errors.go b/internal/database/errors.go similarity index 91% rename from errors.go rename to internal/database/errors.go index 5289617..d4c5e7b 100644 --- a/errors.go +++ b/internal/database/errors.go @@ -1,4 +1,4 @@ -package main +package database import "fmt" diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..7e0b93b --- /dev/null +++ b/internal/status/status.go @@ -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, + }, + } +} diff --git a/ui.go b/internal/ui/ui.go similarity index 55% rename from ui.go rename to internal/ui/ui.go index 4c5fe31..78a5d56 100644 --- a/ui.go +++ b/internal/ui/ui.go @@ -1,14 +1,19 @@ // Right now this will be a very simple, scuffed interface. -package main +package ui import ( "fmt" - bolt "go.etcd.io/bbolt" + "forge.dananglin.me.uk/code/dananglin/pelican/internal/board" "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) if err != nil { return fmt.Errorf("unable to create the keysEvent channel, %w", err) @@ -18,10 +23,9 @@ func badUI(db *bolt.DB) error { _ = keyboard.Close() }() - // infinite loop - fmt.Println(uiUsage()) + fmt.Println(usage()) - app: +app: for { event := <-keysEvents if event.Err != nil { @@ -32,51 +36,57 @@ func badUI(db *bolt.DB) error { case 'q': break app case 'r': - if err := uiRefreshScuffedBoard(db); err != nil { + if err = refresh(db); err != nil { fmt.Printf("Error: Unable to refresh board, %s\n", err) } 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) } 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) } + 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: fmt.Println("Error: Unknown key event.") } } - // wait for a keyboard press - // take action - // a to add a card - // u to update a card + db.Close() + return nil } -func uiRefreshScuffedBoard(db *bolt.DB) error { - statusList, err := readStatuses(db) +func refresh(db *bolt.DB) error { + statusList, err := board.ReadStatusList(db) if err != nil { - return err + return fmt.Errorf("unable to get the status list, %w", err) } 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("--------------------\n\n\n") return nil } -func uiCreateCard(db *bolt.DB) error { +func newCard(db *bolt.DB) error { title := "A card title" 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 { - return err + if err := board.CreateCard(db, title, content); err != nil { + return fmt.Errorf("unable to create card, %w", err) } fmt.Println("Sample card created successfully.") @@ -84,13 +94,12 @@ func uiCreateCard(db *bolt.DB) error { return nil } -func uiViewCard(db *bolt.DB, id int) error { - card, err := readCard(db, id) +func viewCard(db *bolt.DB, id int) error { + card, err := board.ReadCard(db, id) if err != nil { - return err + return fmt.Errorf("unable to read card, %w", err) } - fmt.Printf("====================\n") fmt.Printf("[%d] %s\n", card.ID, card.Title) fmt.Printf("--------------------\n") @@ -100,14 +109,28 @@ func uiViewCard(db *bolt.DB, id int) error { 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 := ` -Press 'q' to quit +Press 'o' to open the project Press 'r' to refresh the board Press 'a' to add a sample card Press 'v' to view the first sample card +Press 'q' to quit ` - return usage + return usage } diff --git a/magefile.go b/magefile.go index 4169806..c13d959 100644 --- a/magefile.go +++ b/magefile.go @@ -22,7 +22,7 @@ var Default = Build func Test() error { goTest := sh.RunCmd("go", "test") - args := []string{"."} + args := []string{"./..."} if os.Getenv("PELICAN_TEST_VERBOSE") == "1" { args = append(args, "-v") @@ -42,7 +42,8 @@ func Lint() error { // Build build the executable 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 diff --git a/main.go b/main.go deleted file mode 100644 index 040a1f1..0000000 --- a/main.go +++ /dev/null @@ -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) - } -} diff --git a/status.go b/status.go deleted file mode 100644 index 4b65367..0000000 --- a/status.go +++ /dev/null @@ -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, - }, - } -}