feat: add the pelican project

This commit is contained in:
Dan Anglin 2023-05-06 12:49:40 +01:00
parent 83fe86d851
commit b983f8930e
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
22 changed files with 2372 additions and 2 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/test/databases/*.db
/pelican

35
.golangci.yaml Normal file
View file

@ -0,0 +1,35 @@
---
run:
concurrency: 2
timeout: 1m
issues-exit-code: 1
tests: true
output:
format: colored-line-number
print-issues-lines: true
print-linter-name: true
uniq-by-line: true
sort-results: true
linters-settings:
exhaustivestruct:
struct-patterns:
- 'forge.dananglin.me.uk/code/dananglin/pelican.Status'
- 'forge.dananglin.me.uk/code/dananglin/pelican.Card'
lll:
line-length: 140
testpackage:
skip-regexp: (internal)_test\.go
linters:
enable-all: true
disable:
- gomnd
fast: false
issues:
exclude-rules:
- path: db_internal_test.go
linters:
- funlen

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Dan Anglin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -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/canal/canal.db
If $XDG\_DATA\_HOME is not set then the default location is $HOME/.local/share/canal/canal.db by default.
For all other operating systems the default location is $HOME/.canal/canal.db.
## Keybindings
## Inspiration
[The toukan project](https://github.com/witchard/toukan).

15
cmd/pelican/main.go Normal file
View file

@ -0,0 +1,15 @@
package main
import (
"log"
"codeflow.dananglin.me.uk/apollo/pelican/internal/ui"
)
func main() {
pelican := ui.NewUI()
if err := pelican.Run(); err != nil {
log.Fatalf("Error: an error occurred while running pelican, %s", err)
}
}

20
go.mod Normal file
View file

@ -0,0 +1,20 @@
module codeflow.dananglin.me.uk/apollo/pelican
go 1.20
require (
github.com/gdamore/tcell/v2 v2.6.0
github.com/magefile/mage v1.14.0
github.com/rivo/tview v0.0.0-20230320095235-84f9c0ff9de8
go.etcd.io/bbolt v1.3.7
)
require (
github.com/gdamore/encoding v1.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.4.3 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
)

50
go.sum Normal file
View file

@ -0,0 +1,50 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg=
github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/rivo/tview v0.0.0-20230320095235-84f9c0ff9de8 h1:wthS/rREJ6WlALtQ3Ysp4Cty/qiY2LNslV90U71bNg0=
github.com/rivo/tview v0.0.0-20230320095235-84f9c0ff9de8/go.mod h1:nVwGv4MP47T0jvlk7KuTTjjuSmrGO4JF0iaiNt4bufE=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

342
internal/board/board.go Normal file
View file

@ -0,0 +1,342 @@
package board
import (
"bytes"
"encoding/gob"
"fmt"
"sort"
"codeflow.dananglin.me.uk/apollo/pelican/internal/db"
bolt "go.etcd.io/bbolt"
)
type Board struct {
db *bolt.DB
}
// Open reads the board from the database.
// If no board exists then a new one will be created.
func Open(path string) (Board, error) {
database, err := db.OpenDatabase(path)
if err != nil {
return Board{}, fmt.Errorf("unable to open the db. %w", err)
}
board := Board{
db: database,
}
statusList, err := board.StatusList()
if err != nil {
return Board{}, err
}
if len(statusList) == 0 {
newStatusList := defaultStatusList()
boltItems := make([]db.BoltItem, len(newStatusList))
for i := range newStatusList {
boltItems[i] = &newStatusList[i]
}
if _, err := db.WriteMany(database, db.StatusBucket, boltItems); err != nil {
return Board{}, fmt.Errorf("unable to save the default status list to the db. %w", err)
}
}
return board, nil
}
// Close closes the project's Kanban board.
func (b *Board) Close() error {
if b.db == nil {
return nil
}
if err := b.db.Close(); err != nil {
return fmt.Errorf("error closing the db. %w", err)
}
return nil
}
// StatusList returns the ordered list of statuses from the db.
func (b *Board) StatusList() ([]Status, error) {
data, err := db.ReadAll(b.db, db.StatusBucket)
if err != nil {
return nil, fmt.Errorf("unable to read the status list, %w", err)
}
statuses := make([]Status, len(data))
for ind, d := range data {
buf := bytes.NewBuffer(d)
decoder := gob.NewDecoder(buf)
var status Status
if err := decoder.Decode(&status); err != nil {
return nil, fmt.Errorf("unable to decode data, %w", err)
}
statuses[ind] = status
}
sort.Sort(ByStatusPosition(statuses))
return statuses, nil
}
// Status returns a single status from the db.
// TODO: Add a test case that handles when a status does not exist.
// Or use in delete status case.
func (b *Board) Status(id int) (Status, error) {
data, err := db.Read(b.db, db.StatusBucket, id)
if err != nil {
return Status{}, fmt.Errorf("unable to read status [%d] from the db. %w", id, err)
}
if data == nil {
return Status{}, StatusNotExistError{ID: id}
}
var status Status
buf := bytes.NewBuffer(data)
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&status); err != nil {
return Status{}, fmt.Errorf("unable to decode data into a Status object, %w", err)
}
return status, nil
}
type StatusArgs struct {
Name string
Order int
}
// CreateStatus creates a status in the db.
func (b *Board) CreateStatus(args StatusArgs) error {
status := Status{
ID: -1,
Name: args.Name,
Position: args.Order,
CardIds: nil,
}
if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil {
return fmt.Errorf("unable to write the status to the db. %w", err)
}
return nil
}
type UpdateStatusArgs struct {
StatusID int
StatusArgs
}
// UpdateStatus modifies an existing status in the db.
func (b *Board) UpdateStatus(args UpdateStatusArgs) error {
status, err := b.Status(args.StatusID)
if err != nil {
return fmt.Errorf("unable to retrieve the status from the db. %w", err)
}
if len(args.Name) > 0 {
status.Name = args.Name
}
if args.Order > 0 {
status.Position = args.Order
}
if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil {
return fmt.Errorf("unable to write the status to the db. %w", err)
}
return nil
}
// TODO: Finish implementation.
// func (b *Board) DeleteStatus() error {
// return nil
// }
type MoveToStatusArgs struct {
CardID int
CurrentStatusID int
NextStatusID int
}
// MoveToStatus moves a card between statuses.
func (b *Board) MoveToStatus(args MoveToStatusArgs) error {
currentStatus, err := b.Status(args.CurrentStatusID)
if err != nil {
return fmt.Errorf("unable to get the card's current status [%d], %w", args.CurrentStatusID, err)
}
nextStatus, err := b.Status(args.NextStatusID)
if err != nil {
return fmt.Errorf("unable to get the card's next status [%d], %w", args.NextStatusID, err)
}
nextStatus.AddCardID(args.CardID)
currentStatus.RemoveCardID(args.CardID)
boltItems := []db.BoltItem{&currentStatus, &nextStatus}
if _, err := db.WriteMany(b.db, db.StatusBucket, boltItems); err != nil {
return fmt.Errorf("unable to update the statuses in the db. %w", err)
}
return nil
}
type CardArgs struct {
NewTitle string
NewContent string
}
// CreateCard creates a card in the database.
func (b *Board) CreateCard(args CardArgs) (int, error) {
statusList, err := b.StatusList()
if err != nil {
return 0, fmt.Errorf("unable to read the status list, %w", err)
}
if len(statusList) == 0 {
return 0, StatusListEmptyError{}
}
card := Card{
ID: -1,
Title: args.NewTitle,
Content: args.NewContent,
}
cardID, err := db.Write(b.db, db.CardBucket, &card)
if err != nil {
return 0, fmt.Errorf("unable to write card to the db. %w", err)
}
initialStatus := statusList[0]
initialStatus.AddCardID(cardID)
id, err := db.Write(b.db, db.StatusBucket, &initialStatus)
if err != nil {
return 0, fmt.Errorf("unable to write the %s status to the db. %w", initialStatus.Name, err)
}
return id, nil
}
// Card returns a Card value from the database.
// TODO: Handle edge case where the card does not exist in the db.
func (b *Board) Card(cardID int) (Card, error) {
data, err := db.Read(b.db, db.CardBucket, cardID)
if err != nil {
return Card{}, fmt.Errorf("unable to read card [%d] from the db. %w", cardID, err)
}
if data == nil {
return Card{}, CardNotExistError{ID: cardID}
}
var card Card
buf := bytes.NewBuffer(data)
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&card); err != nil {
return Card{}, fmt.Errorf("unable to decode data, %w", err)
}
return card, nil
}
// CardList returns a list of Card values from the db.
// TODO: function needs testing.
func (b *Board) CardList(ids []int) ([]Card, error) {
data, err := db.ReadMany(b.db, db.CardBucket, ids)
if err != nil {
return nil, fmt.Errorf("unable to read card list from the db. %w", err)
}
cards := make([]Card, len(data))
for ind, d := range data {
buf := bytes.NewBuffer(d)
decoder := gob.NewDecoder(buf)
var card Card
if err := decoder.Decode(&card); err != nil {
return nil, fmt.Errorf("unable to decode data, %w", err)
}
cards[ind] = card
}
return cards, nil
}
type UpdateCardArgs struct {
CardID int
CardArgs
}
// UpdateCard modifies an existing card in the database.
func (b *Board) UpdateCard(args UpdateCardArgs) error {
card, err := b.Card(args.CardID)
if err != nil {
return err
}
if len(args.NewTitle) > 0 {
card.Title = args.NewTitle
}
if len(args.NewContent) > 0 {
card.Content = args.NewContent
}
if _, err := db.Write(b.db, db.CardBucket, &card); err != nil {
return fmt.Errorf("unable to write card to the db. %w", err)
}
return nil
}
type DeleteCardArgs struct {
CardID int
StatusID int
}
// DeleteCard deletes a card from the database.
func (b *Board) DeleteCard(args DeleteCardArgs) error {
if err := db.Delete(b.db, db.CardBucket, args.CardID); err != nil {
return fmt.Errorf("unable to delete the card from the database, %w", err)
}
status, err := b.Status(args.StatusID)
if err != nil {
return fmt.Errorf("unable to read Status '%d' from the database, %w", args.StatusID, err)
}
status.RemoveCardID(args.CardID)
if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil {
return fmt.Errorf("unable to update the status in the database, %w", err)
}
return nil
}

28
internal/board/card.go Normal file
View file

@ -0,0 +1,28 @@
package board
import "fmt"
type CardNotExistError struct {
ID int
}
func (e CardNotExistError) Error() string {
return fmt.Sprintf("card ID '%d' does not exist in the database", e.ID)
}
// Card represents a card on a Kanban board.
type Card struct {
ID int
Title string
Content string
}
// UpdateId updates the ID of the Card value.
func (c *Card) UpdateId(id int) {
c.ID = id
}
// Id returns the ID of the Card value.
func (c *Card) Id() int {
return c.ID
}

View file

@ -0,0 +1,227 @@
package board_test
import (
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
)
func TestCardLifecycle(t *testing.T) {
t.Log("Testing the lifecycle of a card.")
projectDir, err := projectRoot()
if err != nil {
t.Fatalf(err.Error())
}
testDBPath := filepath.Join(projectDir, "test", "databases", "Board_TestCardLifecycle.db")
os.Remove(testDBPath)
kanban, err := board.Open(testDBPath)
if err != nil {
t.Fatalf("Unable to open the test Kanban board, %s.", err)
}
defer func() {
_ = kanban.Close()
}()
initialCardTitle := "A test card."
initialCardContent := "Ensure that this card is safely stored in the database."
expectedCardID := 1
expectedStatusID := 1
t.Run("Test Create Card", testCreateCard(kanban, initialCardTitle, initialCardContent, expectedCardID, expectedStatusID))
t.Run("Test Read Card", testReadCard(kanban, expectedCardID, initialCardTitle, initialCardContent))
modifiedCardTitle := "Test card updated."
modifiedCardContent1 := "Ensure that this card is safely updated in the database."
t.Run("Test Update Card", testUpdateCard(kanban, expectedCardID, modifiedCardTitle, modifiedCardContent1))
modifiedCardContent2 := "Updated card content only."
t.Run("Test Update Card Content", testUpdateCardContent(kanban, expectedCardID, modifiedCardTitle, modifiedCardContent2))
t.Run("Test Card Delete", testDeleteCard(kanban, expectedCardID, expectedStatusID))
}
func testCreateCard(kanban board.Board, title, content string, expectedCardID, expectedStatusID int) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When the card is created and saved to the database.")
args := board.CardArgs{
NewTitle: title,
NewContent: content,
}
if _, err := kanban.CreateCard(args); err != nil {
t.Fatalf("ERROR: Unable to create the test card, %s.", err)
}
t.Logf("\t\tVerifying that the card's ID is in the expected status...")
status, err := kanban.Status(expectedStatusID)
if err != nil {
t.Fatalf("ERROR: Unable to read status '%d', %v", expectedStatusID, err)
}
numCardIDs := len(status.CardIds)
if numCardIDs != 1 {
t.Fatalf("ERROR: Unexpected number of cards in status '%d', want: %d, got %d.", expectedStatusID, 1, numCardIDs)
}
if expectedCardID != status.CardIds[0] {
t.Errorf("%s\tUnexpected card ID found in the default status, want: %d, got %d.", failure, expectedCardID, status.CardIds[0])
} else {
t.Logf("%s\tExpected card ID found in the default status, got %d.", success, status.CardIds[0])
}
}
}
func testReadCard(kanban board.Board, cardID int, wantTitle, wantContent string) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When a card is read from the database.")
card, err := kanban.Card(cardID)
if err != nil {
t.Fatalf("ERROR: Unable to read test card, %s.", err)
}
if card.Title != wantTitle {
t.Errorf("%s\tUnexpected card title received, want: %s, got: %s.", failure, wantTitle, card.Title)
} else {
t.Logf("%s\tExpected card title received, got: %s.", success, card.Title)
}
if card.Content != wantContent {
t.Errorf("%s\tUnexpected card content received, want: %s, got: %s.", failure, wantContent, card.Content)
} else {
t.Logf("%s\tExpected card content received, got: %s.", success, card.Content)
}
}
}
func testUpdateCard(kanban board.Board, cardID int, newTitle, newContent string) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When a card is updated in the database.")
args := board.UpdateCardArgs{
CardID: cardID,
CardArgs: board.CardArgs{
NewTitle: newTitle,
NewContent: newContent,
},
}
if err := kanban.UpdateCard(args); err != nil {
t.Fatalf("ERROR: Unable to update the test card, %s", err)
}
got, err := kanban.Card(cardID)
if err != nil {
t.Fatalf("ERROR: Unable to read the modified test card, %s", err)
}
want := board.Card{
ID: cardID,
Title: newTitle,
Content: newContent,
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s\tUnexpected card read from the database: want %+v, got %+v", failure, want, got)
} else {
t.Logf("%s\tExpected card read from the database: got %+v", success, got)
}
}
}
func testUpdateCardContent(kanban board.Board, cardID int, expectedTitle, newContent string) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When (and only when) a card's content is updated in the database.")
args := board.UpdateCardArgs{
CardID: cardID,
CardArgs: board.CardArgs{
NewTitle: "",
NewContent: newContent,
},
}
if err := kanban.UpdateCard(args); err != nil {
t.Fatalf("ERROR: Unable to update the test card, %s", err)
}
got, err := kanban.Card(cardID)
if err != nil {
t.Fatalf("ERROR: Unable to read the modified test card, %s", err)
}
want := board.Card{
ID: cardID,
Title: expectedTitle,
Content: newContent,
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s\tUnexpected card read from the database, want: %+v, got: %+v", failure, want, got)
} else {
t.Logf("%s\tExpected card read from the database, got: %+v", success, got)
}
}
}
func testDeleteCard(kanban board.Board, cardID, statusID int) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When deleting a card from the database.")
args := board.DeleteCardArgs{
CardID: cardID,
StatusID: statusID,
}
if err := kanban.DeleteCard(args); err != nil {
t.Fatalf("ERROR: An error occurred when deleting the card from the database, %v", err)
} else {
t.Logf("%s\tNo errors occurred when deleting the card from the database.", success)
}
t.Logf("\tVerifying that the card is removed from the database...")
_, err := kanban.Card(cardID)
if err == nil {
t.Errorf("%s\tDid not receive the expected error when attempting to read the deleted card.", failure)
} else {
if errors.Is(err, board.CardNotExistError{}) {
t.Errorf(
"%s\tDid not receive the expected board.CardNotExistError when attempting to retrieve the deleted card, instead got '%v'.",
failure,
err,
)
} else {
t.Logf("%s\tSuccessfully received board.CardNotExistError when attempting to retrieve the deleted card.", success)
}
}
t.Logf("\tVerifying that the card's ID is removed from the status list...")
status, err := kanban.Status(statusID)
if err != nil {
t.Fatalf("ERROR: Unable to read status '%d' from the database; %v", statusID, err)
}
numCardIDs := len(status.CardIds)
if numCardIDs != 0 {
t.Errorf("%s\tUnexpected non-empty list of card IDs in status '%d', got '%+v' card IDs.", failure, statusID, status.CardIds)
} else {
t.Logf("%s\tThe card ID was successfully removed from the list of card in status '%d'.", success, statusID)
}
}
}

View file

@ -0,0 +1,21 @@
package board_test
import (
"fmt"
"os"
"path/filepath"
)
const (
success = "\u2713"
failure = "\u2717"
)
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
}

134
internal/board/status.go Normal file
View file

@ -0,0 +1,134 @@
package board
import (
"sort"
"fmt"
)
type StatusListEmptyError struct{}
func (e StatusListEmptyError) Error() string {
return "the status list must not be empty"
}
type StatusNotExistError struct {
ID int
}
func (e StatusNotExistError) Error() string {
return fmt.Sprintf("status ID '%d' does not exist in the database", e.ID)
}
// Status represents the status of the Kanban board.
type Status struct {
ID int
Name string
CardIds []int
Position int
}
// UpdateID updates the ID of the Status value.
func (s *Status) UpdateId(id int) {
s.ID = id
}
// Id returns the ID of the Status value.
func (s *Status) Id() int {
return s.ID
}
// AddCardID adds a card ID to the status' list of card IDs.
func (s *Status) AddCardID(cardID int) {
// Create a new list if it does not exist
// and then return.
if s.CardIds == nil {
s.CardIds = []int{cardID}
return
}
// Sort list if not sorted.
if !sort.IntsAreSorted(s.CardIds) {
sort.Ints(s.CardIds)
}
// Get index of the card's ID if it already exists in the list.
// Return if it already exists in the list
ind := sort.SearchInts(s.CardIds, cardID)
if ind < len(s.CardIds) && cardID == s.CardIds[ind] {
return
}
s.CardIds = append(s.CardIds, cardID)
sort.Ints(s.CardIds)
}
// RemoveCardID removes a card ID from the status' list of card IDs.
func (s *Status) RemoveCardID(cardID int) {
if s.CardIds == nil {
return
}
// Sort list if not sorted.
if !sort.IntsAreSorted(s.CardIds) {
sort.Ints(s.CardIds)
}
// Get index of id.
// If the card ID is somehow not in the list, then ind
// will be the index where the id can be inserted.
ind := sort.SearchInts(s.CardIds, cardID)
if ind >= len(s.CardIds) || cardID != s.CardIds[ind] {
return
}
if len(s.CardIds) == 1 {
s.CardIds = nil
return
}
// use append to eliminate the id from the new slice
s.CardIds = append(s.CardIds[:ind], s.CardIds[ind+1:]...)
}
// ByStatusPosition implements sort.Interface for []Status based on the status' position on the Kanban board.
type ByStatusPosition []Status
func (s ByStatusPosition) Len() int {
return len(s)
}
func (s ByStatusPosition) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s ByStatusPosition) Less(i, j int) bool {
return s[i].Position < s[j].Position
}
// defaultStatusList returns the default list of statuses.
func defaultStatusList() []Status {
return []Status{
{
ID: -1,
Name: "To Do",
Position: 1,
CardIds: nil,
},
{
ID: -1,
Name: "Doing",
Position: 2,
CardIds: nil,
},
{
ID: -1,
Name: "Done",
Position: 3,
CardIds: nil,
},
}
}

View file

@ -0,0 +1,183 @@
package board_test
import (
"os"
"path/filepath"
"reflect"
"testing"
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
)
func TestStatusLifecycle(t *testing.T) {
t.Log("Testing the lifecycle of a status.")
projectDir, err := projectRoot()
if err != nil {
t.Fatalf(err.Error())
}
testDBPath := filepath.Join(projectDir, "test", "databases", "Board_TestStatusLifecycle.db")
os.Remove(testDBPath)
kanban, err := board.Open(testDBPath)
if err != nil {
t.Fatalf("Unable to open the test Kanban board, %s.", err)
}
defer func() {
_ = kanban.Close()
}()
// We've opened a new board with the default list of statuses: To Do, Doing, Done
statusOnHoldName := "On Hold"
statusOnHoldExpectedID := 4
statusOnHoldOrder := 4
t.Run("Test Create Status", testCreateStatus(kanban, statusOnHoldName, statusOnHoldOrder))
t.Run("Test Read Status", testReadStatus(kanban, statusOnHoldExpectedID, statusOnHoldName))
t.Run("Test Status Update", testUpdateStatus(kanban, 2, "In Progress"))
// Rearrange the board so the order is To Do, On Hold, In Progress, Done
// Move a Card ID from To Do to In Progress
t.Run("Test Move Card To Status", testMoveCardToStatus(kanban))
}
func testCreateStatus(kanban board.Board, name string, order int) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When the status is created and saved to the database.")
args := board.StatusArgs{
Name: name,
Order: order,
}
if err := kanban.CreateStatus(args); err != nil {
t.Fatalf("ERROR: Unable to create the new status, %v.", err)
}
t.Logf("%s\tStatus '%s' was successfully saved to the database.", success, name)
}
}
func testReadStatus(kanban board.Board, statusID int, wantName string) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When the status is read from the database.")
status, err := kanban.Status(statusID)
if err != nil {
t.Fatalf("ERROR: Unable to read the test status, %v", err)
}
if status.Name != wantName {
t.Errorf("%s\tUnexpected status received from the database, want: '%s', got: '%s'", failure, wantName, status.Name)
} else {
t.Logf("%s\tSuccessfully received the '%s' status from the database.", success, status.Name)
}
}
}
func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When the status' name is updated in the database.")
args := board.UpdateStatusArgs{
StatusID: statusID,
StatusArgs: board.StatusArgs{
Name: newName,
Order: -1,
},
}
if err := kanban.UpdateStatus(args); err != nil {
t.Fatalf("ERROR: Unable to update the status, %v", err)
} else {
t.Logf("%s\tStatus successfully updated.", success)
}
t.Log("\tVerifying the new status...")
status, err := kanban.Status(statusID)
if err != nil {
t.Fatalf("ERROR: Unable to retrieve the status from the database, %v", err)
}
want := board.Status{
ID: statusID,
Name: newName,
CardIds: nil,
Position: 2,
}
if !reflect.DeepEqual(status, want) {
t.Errorf("%s\tUnexpected status received from the database, want: %+v, got: %+v", failure, want, status)
} else {
t.Logf("%s\tExpected status name received from the database, got: %+v", success, status)
}
}
}
// func testRearrangeBoard() func(t *testing.T) {
// return func(t *testing.T) {
// }
// }
func testMoveCardToStatus(kanban board.Board) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When moving a card between statuses.")
title := "Test card."
cardArgs := board.CardArgs{NewTitle: title, NewContent: ""}
cardID, err := kanban.CreateCard(cardArgs)
if err != nil {
t.Fatalf("ERROR: Unable to create the card in the database, %v", err)
}
statusList, err := kanban.StatusList()
if err != nil {
t.Fatalf("ERROR: Unable to retrieve the list of statuses from the database, %v", err)
}
status0, status2 := statusList[0], statusList[2]
moveArgs := board.MoveToStatusArgs{CardID: cardID, CurrentStatusID: status0.ID, NextStatusID: status2.ID}
if err := kanban.MoveToStatus(moveArgs); err != nil {
t.Fatalf("ERROR: Unable to move the Card ID from '%s' to '%s', %v", status0.Name, status2.Name, err)
}
t.Logf("\tVerifying that the card has moved to '%s'...", status2.Name)
statusList, err = kanban.StatusList()
if err != nil {
t.Fatalf("ERROR: Unable to retrieve the list of statuses from the database, %v", err)
}
status0, status2 = statusList[0], statusList[2]
if len(status0.CardIds) != 0 {
t.Errorf("%s\tUnexpected number of card IDs found in '%s', want: 0, got: %d", failure, status0.Name, len(status0.CardIds))
} else {
t.Logf("%s\tThe number of card IDs in '%s' is now %d", success, status0.Name, len(status0.CardIds))
}
if len(status2.CardIds) != 1 {
t.Errorf("%s\tUnexpected number of card IDs found in '%s', want: 1, got: %d", failure, status2.Name, len(status2.CardIds))
} else {
t.Logf("%s\tThe number of card IDs in '%s' is now %d", success, status2.Name, len(status2.CardIds))
}
card, err := kanban.Card(status2.CardIds[0])
if err != nil {
t.Fatalf("ERROR: Unable to retrieve the card from the database, %v", err)
}
if card.Title != title {
t.Errorf("%s\tUnexpected card title found in '%s', want: '%s', got: '%s'", success, status2.Name, title, card.Title)
} else {
t.Logf("%s\tExpected card title found in '%s', got: '%s'", success, status2.Name, card.Title)
}
}
}

309
internal/db/database.go Normal file
View file

@ -0,0 +1,309 @@
package db
import (
"bytes"
"encoding/gob"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"time"
bolt "go.etcd.io/bbolt"
)
const (
StatusBucket string = "status"
CardBucket string = "card"
)
type BoltItem interface {
UpdateId(int)
Id() int
}
// 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)
}
opts := bolt.Options{
Timeout: 1 * time.Second,
}
var database *bolt.DB
if database, err = bolt.Open(path, 0o600, &opts); err != nil {
return nil, fmt.Errorf("unable to open database at %s, %w", path, err)
}
if err = ensureBuckets(database); err != nil {
return nil, fmt.Errorf("unable to ensure the required buckets are in the database, %w", err)
}
return database, nil
}
// Read retrieves a Bolt item from a specified bucket and returns the data in bytes.
func Read(db *bolt.DB, bucketName string, itemID int) ([]byte, error) {
bucketNameBytes := []byte(bucketName)
var data []byte
if err := db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketNameBytes)
if bucket == nil {
return bucketNotExistError{bucket: string(bucketNameBytes)}
}
data = bucket.Get([]byte(strconv.Itoa(itemID)))
return nil
}); err != nil {
return nil, fmt.Errorf("error while reading the Bolt item from the database, %w", err)
}
return data, nil
}
// ReadMany reads one or more Bolt items from the specified bucket.
func ReadMany(db *bolt.DB, bucketName string, ids []int) ([][]byte, error) {
bucketNameBytes := []byte(bucketName)
var output [][]byte
err := db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketNameBytes)
if bucket == nil {
return bucketNotExistError{bucket: bucketName}
}
for _, v := range ids {
data := bucket.Get([]byte(strconv.Itoa(v)))
output = append(output, data)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("error while retrieving the data from the database, %w", err)
}
return output, nil
}
// ReadAll retrieves all the Bolt Items from the specified bucket.
func ReadAll(db *bolt.DB, bucketName string) ([][]byte, error) {
bucketNameBytes := []byte(bucketName)
var output [][]byte
err := db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketNameBytes)
if bucket == nil {
return bucketNotExistError{bucket: bucketName}
}
if err := bucket.ForEach(func(_, v []byte) error {
output = append(output, v)
return nil
}); err != nil {
return fmt.Errorf("unable to load status, %w", err)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("error while loading statuses from the database, %w", err)
}
return output, nil
}
// Write creates or updates a Bolt item to a specified bucket.
func Write(db *bolt.DB, bucketName string, item BoltItem) (int, error) {
bucketNameBytes := []byte(bucketName)
err := db.Update(func(tx *bolt.Tx) error {
var err error
bucket := tx.Bucket(bucketNameBytes)
if bucket == nil {
return bucketNotExistError{bucket: string(bucketNameBytes)}
}
if item.Id() < 1 {
var id uint64
if id, err = bucket.NextSequence(); err != nil {
return fmt.Errorf("unable to generate an ID for the card, %w", err)
}
item.UpdateId(int(id))
}
buf := new(bytes.Buffer)
encoder := gob.NewEncoder(buf)
if err = encoder.Encode(item); err != nil {
return fmt.Errorf("unable to encode data, %w", err)
}
if err = bucket.Put([]byte(strconv.Itoa(item.Id())), buf.Bytes()); err != nil {
return fmt.Errorf("unable to write the card to the bucket, %w", err)
}
return nil
})
if err != nil {
return 0, fmt.Errorf("error while saving the card to the database, %w", err)
}
return item.Id(), nil
}
// WriteMany saves one or more Bolt items to the status bucket.
func WriteMany(database *bolt.DB, bucketName string, items []BoltItem) ([]int, error) {
if len(items) == 0 {
return []int{}, nil
}
ids := make([]int, len(items))
bucketNameBytes := []byte(bucketName)
err := database.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketNameBytes)
if bucket == nil {
return bucketNotExistError{bucket: string(bucketNameBytes)}
}
for ind, item := range items {
var err error
if item.Id() < 1 {
var id uint64
if id, err = bucket.NextSequence(); err != nil {
return fmt.Errorf("unable to generate ID, %w", err)
}
item.UpdateId(int(id))
}
buf := new(bytes.Buffer)
encoder := gob.NewEncoder(buf)
if err = encoder.Encode(item); err != nil {
return fmt.Errorf("unable to encode data, %w", err)
}
if err = bucket.Put([]byte(strconv.Itoa(item.Id())), buf.Bytes()); err != nil {
return fmt.Errorf("unable to add the Bolt Item to the %s bucket, %w", bucketName, err)
}
ids[ind] = item.Id()
}
return nil
})
if err != nil {
return nil, fmt.Errorf("error while saving the Bolt Items to the database, %w", err)
}
return ids, nil
}
// Delete deletes a Bolt item from a specified bucket.
func Delete(db *bolt.DB, bucketName string, itemID int) error {
bucketNameBytes := []byte(bucketName)
if err := db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketNameBytes)
if bucket == nil {
return bucketNotExistError{bucket: bucketName}
}
if err := bucket.Delete([]byte(strconv.Itoa(itemID))); err != nil {
return fmt.Errorf("an error occurred when deleting Bolt item '%d', %w", itemID, err)
}
return nil
}); err != nil {
return fmt.Errorf("error deleting data from the '%s' bucket, %w", bucketName, 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/canal/canal.db. If the XDG_DATA_HOME environment
// variable is not set then it will default to $HOME/.local/share/canal/canal.db. For all other operating systems the default
// location is $HOME/.canal/canal.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
}

View file

@ -0,0 +1,392 @@
package db_test
import (
"bytes"
"encoding/gob"
"os"
"path/filepath"
"reflect"
"testing"
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
"codeflow.dananglin.me.uk/apollo/pelican/internal/db"
bolt "go.etcd.io/bbolt"
)
func TestOpenDataBaseXDGDataDir(t *testing.T) {
projectDir, err := projectRoot()
if err != nil {
t.Fatalf(err.Error())
}
testXdgDataHome := filepath.Join(projectDir, "test", "databases", "xdg_data_dir")
defer os.RemoveAll(testXdgDataHome)
t.Setenv("XDG_DATA_HOME", testXdgDataHome)
db, err := db.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 database *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 database, err = db.OpenDatabase(testDB); err != nil {
t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err)
}
defer func() {
_ = database.Close()
}()
testWriteStatusList(t, database)
testReadStatusList(t, database)
}
func testWriteStatusList(t *testing.T, database *bolt.DB) {
t.Helper()
newStatusList := []board.Status{
{
ID: -1,
Name: "Backlog",
CardIds: []int{1, 14, 9, 10},
Position: 1,
},
{
ID: -1,
Name: "Next",
CardIds: []int{2, 5, 12},
Position: 2,
},
{
ID: -1,
Name: "In progress",
CardIds: []int{3, 14},
Position: 3,
},
{
ID: -1,
Name: "Finished!",
CardIds: []int{4, 6, 7, 8, 11, 13},
Position: 4,
},
}
boltItems := make([]db.BoltItem, len(newStatusList))
for i := range newStatusList {
boltItems[i] = &newStatusList[i]
}
if _, err := db.WriteMany(database, db.StatusBucket, boltItems); err != nil {
t.Fatalf("An error occurred whilst writing the initial status list to the database, %s", err)
}
}
func testReadStatusList(t *testing.T, database *bolt.DB) {
t.Helper()
data, err := db.ReadAll(database, db.StatusBucket)
if err != nil {
t.Fatalf("An error occurred whilst reading the modified status list from the database, %s", err)
}
got := make([]board.Status, len(data))
for ind, d := range data {
buf := bytes.NewBuffer(d)
decoder := gob.NewDecoder(buf)
var status board.Status
if err := decoder.Decode(&status); err != nil {
t.Fatalf("An error occurred whilst decoding data, %s", err)
}
got[ind] = status
}
want := []board.Status{
{
ID: 1,
Name: "Backlog",
CardIds: []int{1, 14, 9, 10},
Position: 1,
},
{
ID: 2,
Name: "Next",
CardIds: []int{2, 5, 12},
Position: 2,
},
{
ID: 3,
Name: "In progress",
CardIds: []int{3, 14},
Position: 3,
},
{
ID: 4,
Name: "Finished!",
CardIds: []int{4, 6, 7, 8, 11, 13},
Position: 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 TestReadAndWriteCards(t *testing.T) {
t.Parallel()
var database *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 database, err = db.OpenDatabase(testDB); err != nil {
t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err)
}
defer func() {
_ = database.Close()
}()
singleCard := board.Card{
ID: -1,
Title: "A test task.",
Content: "This task should be completed.",
}
singleCardID := testWriteOneCard(t, database, singleCard)
testReadOneCard(t, database, singleCardID)
manyCards := []board.Card{
{
ID: -1,
Title: "Test card A.",
Content: "This is test card A.",
},
{
ID: -1,
Title: "Test card B.",
Content: "This is test card B.",
},
{
ID: -1,
Title: "Test card C.",
Content: "This is test card C.",
},
}
manyCardIDs := testWriteManyCard(t, database, manyCards)
testReadManyCards(t, database, manyCardIDs)
}
func testWriteOneCard(t *testing.T, database *bolt.DB, card board.Card) int {
t.Helper()
cardID, err := db.Write(database, db.CardBucket, &card)
if err != nil {
t.Fatalf("An error occurred whilst writing the card to the database, %s", err)
}
return cardID
}
func testReadOneCard(t *testing.T, database *bolt.DB, cardID int) {
t.Helper()
data, err := db.Read(database, db.CardBucket, cardID)
if err != nil {
t.Fatalf("An error occurred whilst loading the modified from the database, %s", err)
}
var got board.Card
buf := bytes.NewBuffer(data)
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&got); err != nil {
t.Fatalf("Unable to decode data, %s", err)
}
want := board.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 testWriteManyCard(t *testing.T, database *bolt.DB, cards []board.Card) []int {
t.Helper()
boltItems := make([]db.BoltItem, len(cards))
for i := range cards {
boltItems[i] = &cards[i]
}
ids, err := db.WriteMany(database, db.CardBucket, boltItems)
if err != nil {
t.Fatalf("An error occurred whilst writing many cards to the database, %s", err)
}
return ids
}
func testReadManyCards(t *testing.T, database *bolt.DB, cardIDs []int) {
t.Helper()
data, err := db.ReadMany(database, db.CardBucket, cardIDs)
if err != nil {
t.Fatalf("An error occurred whilst reading the data from the database, %s", err)
}
got := make([]board.Card, len(data))
for i, d := range data {
buf := bytes.NewBuffer(d)
decoder := gob.NewDecoder(buf)
var c board.Card
if err := decoder.Decode(&c); err != nil {
t.Fatalf("An error occurred whilst decoding data, %s", err)
}
got[i] = c
}
want := []board.Card{
{
ID: 2,
Title: "Test card A.",
Content: "This is test card A.",
},
{
ID: 3,
Title: "Test card B.",
Content: "This is test card B.",
},
{
ID: 4,
Title: "Test card C.",
Content: "This is test card C.",
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Unexpected list of cards read from the database: got %+v, want %+v", got, want)
} else {
t.Logf("Expected list of cards read from the database: got %+v", got)
}
}
func TestDeleteOneCard(t *testing.T) {
t.Parallel()
var database *bolt.DB
var err error
projectDir, err := projectRoot()
if err != nil {
t.Fatalf(err.Error())
}
testDB := filepath.Join(projectDir, "test", "databases", "Database_TestDeleteOneCard.db")
os.Remove(testDB)
if database, err = db.OpenDatabase(testDB); err != nil {
t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err)
}
defer func() {
_ = database.Close()
}()
// Create one card, get card ID.
card := board.Card{
ID: -1,
Title: "Test card",
Content: "",
}
cardID, err := db.Write(database, db.CardBucket, &card)
if err != nil {
t.Fatalf("ERROR: Unable to create the card in the database, %v", err)
}
cards, err := db.ReadAll(database, db.CardBucket)
if err != nil {
t.Fatalf("ERROR: Unable to read the cards from the database, %v", err)
}
numCards := len(cards)
if numCards != 1 {
t.Fatalf("ERROR: Unexpected number of cards returned from the card bucket; want 1; got %d", numCards)
}
if err := db.Delete(database, db.CardBucket, cardID); err != nil {
t.Fatalf("ERROR: Unable to delete the card from the database, %v", err)
}
// Get all cards, expect length = 0; error if not 0
cards, err = db.ReadAll(database, db.CardBucket)
if err != nil {
t.Fatalf("ERROR: Unable to read the cards from the database, %v", err)
}
numCards = len(cards)
if numCards != 0 {
t.Errorf("%s\tUnexpected number of cards returned from the card bucket; want 0; got %d", failure, numCards)
} else {
t.Logf("%s\tThe card was successfully deleted from the database.", success)
}
}

11
internal/db/errors.go Normal file
View file

@ -0,0 +1,11 @@
package db
import "fmt"
type bucketNotExistError struct {
bucket string
}
func (e bucketNotExistError) Error() string {
return fmt.Sprintf("bucket %s does not exist", e.bucket)
}

View file

@ -0,0 +1,21 @@
package db_test
import (
"fmt"
"os"
"path/filepath"
)
const (
success = "\u2713"
failure = "\u2717"
)
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
}

120
internal/ui/column.go Normal file
View file

@ -0,0 +1,120 @@
package ui
import (
"fmt"
"strconv"
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type column struct {
statusID int
cards *tview.List
}
func (u *UI) newColumn(status board.Status) (column, error) {
cardList := tview.NewList()
cardList.SetBorder(true)
cardList.ShowSecondaryText(false)
cardList.SetTitle(" " + status.Name + " ")
cardList.SetHighlightFullLine(true)
cardList.SetSelectedFocusOnly(true)
cardList.SetWrapAround(false)
cardList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'a':
u.pages.ShowPage(addPageName)
u.SetFocus(u.addModal)
case 'h':
u.shiftColumnFocus(shiftLeft)
case 'l':
u.shiftColumnFocus(shiftRight)
case 'j':
cur := cardList.GetCurrentItem()
if cur == cardList.GetItemCount()-1 {
cur = 0
} else {
cur++
}
cardList.SetCurrentItem(cur)
case 'k':
cur := cardList.GetCurrentItem()
cur--
cardList.SetCurrentItem(cur)
case 'm':
u.pages.ShowPage(movePageName)
u.SetFocus(u.move)
}
switch event.Key() {
case tcell.KeyCtrlQ:
u.pages.ShowPage(quitPageName)
u.SetFocus(u.quitModal)
case tcell.KeyCtrlD:
u.pages.ShowPage(deleteCardPageName)
u.SetFocus(u.deleteCardModal)
}
return event
})
if status.CardIds != nil && len(status.CardIds) > 0 {
cards, err := u.board.CardList(status.CardIds)
if err != nil {
return column{}, fmt.Errorf("unable to get the card list. %w", err)
}
for _, c := range cards {
id := strconv.Itoa(c.ID)
cardList.AddItem(fmt.Sprintf("[%s] %s", id, c.Title), id, 0, nil)
}
}
u.flex.AddItem(cardList, 0, 1, false)
c := column{
statusID: status.ID,
cards: cardList,
}
return c, nil
}
func (u *UI) setColumnFocus() {
u.SetFocus(u.columns[u.focusedColumn].cards)
}
func (u *UI) shiftColumnFocus(s int) {
switch s {
case shiftRight:
if u.focusedColumn == len(u.columns)-1 {
u.focusedColumn = 0
} else {
u.focusedColumn++
}
case shiftLeft:
if u.focusedColumn == 0 {
u.focusedColumn = len(u.columns) - 1
} else {
u.focusedColumn--
}
}
u.setColumnFocus()
}
func (u *UI) updateColumns(statusList []board.Status) {
u.flex.Clear()
columns := make([]column, len(statusList))
for i := range statusList {
columns[i], _ = u.newColumn(statusList[i])
}
u.columns = columns
}

86
internal/ui/modalinput.go Normal file
View file

@ -0,0 +1,86 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type modalInput struct {
*tview.Form
frame *tview.Frame
text string
done func(string, bool)
}
func NewModalInput() *modalInput {
form := tview.NewForm()
m := modalInput{
Form: form,
frame: tview.NewFrame(form),
text: "",
done: nil,
}
m.SetButtonsAlign(tview.AlignCenter).
SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
SetButtonTextColor(tview.Styles.PrimaryTextColor).
SetBackgroundColor(tview.Styles.ContrastBackgroundColor).
SetBorderPadding(0, 0, 0, 0)
m.AddInputField("", "", 50, nil, func(text string) {
m.text = text
})
m.AddButton("OK", func() {
if m.done != nil {
m.done(m.text, true)
}
})
m.AddButton("Cancel", func() {
if m.done != nil {
m.done(m.text, false)
}
})
m.frame.SetBorders(0, 0, 1, 0, 0, 0).
SetBorder(true).
SetBackgroundColor(tview.Styles.ContrastBackgroundColor).
SetBorderPadding(1, 1, 1, 1)
return &m
}
func (m *modalInput) SetValue(text string) {
m.Clear(false)
m.AddInputField("", text, 50, nil, func(text string) {
m.text = text
})
}
func (m *modalInput) SetDoneFunc(handler func(string, bool)) *modalInput {
m.done = handler
return m
}
func (m *modalInput) Draw(screen tcell.Screen) {
buttonsWidth := 50
screenWidth, screenHeight := screen.Size()
width := screenWidth / 3
if width < buttonsWidth {
width = buttonsWidth
}
height := 7
width += 4
// Set the modal's position and size.
x := (screenWidth - width) / 2
y := (screenHeight - height) / 2
m.SetRect(x, y, width, height)
// Draw the frame.
m.frame.SetRect(x, y, width, height)
m.frame.Draw(screen)
}

265
internal/ui/ui.go Normal file
View file

@ -0,0 +1,265 @@
package ui
import (
"fmt"
"strconv"
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
shiftLeft int = iota
shiftRight
)
const (
mainPageName string = "main"
quitPageName string = "quit"
addPageName string = "add"
movePageName string = "move"
deleteCardPageName string = "delete card"
)
type UI struct {
*tview.Application
columns []column
flex *tview.Flex
pages *tview.Pages
focusedColumn int
board board.Board
quitModal *tview.Modal
addModal *modalInput
move *tview.Flex
deleteCardModal *tview.Modal
}
// NewUI returns a new UI value.
func NewUI() UI {
ui := UI{
Application: tview.NewApplication(),
pages: tview.NewPages(),
flex: tview.NewFlex(),
quitModal: tview.NewModal(),
addModal: NewModalInput(),
focusedColumn: 0,
columns: nil,
move: nil,
board: board.Board{},
deleteCardModal: tview.NewModal(),
}
ui.init()
return ui
}
// closeBoard closes the board.
func (u *UI) closeBoard() {
_ = u.board.Close()
}
// deleteCard deletes a card from the board.
func (u *UI) deleteCard() {
currentItem := u.columns[u.focusedColumn].cards.GetCurrentItem()
_, cardIDText := u.columns[u.focusedColumn].cards.GetItemText(currentItem)
cardID, _ := strconv.Atoi(cardIDText)
statusID := u.columns[u.focusedColumn].statusID
args := board.DeleteCardArgs{
CardID: cardID,
StatusID: statusID,
}
_ = u.board.DeleteCard(args)
}
// init initialises the UI.
func (u *UI) init() {
u.pages.AddPage(mainPageName, u.flex, true, true)
u.initQuitModal()
u.pages.AddPage(quitPageName, u.quitModal, false, false)
u.initAddInputModal()
u.pages.AddPage(addPageName, u.addModal, false, false)
u.initDeleteCardModal()
u.pages.AddPage(deleteCardPageName, u.deleteCardModal, false, false)
u.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'o':
if u.flex.HasFocus() && len(u.columns) == 0 {
_ = u.openBoard("")
}
}
return event
})
u.SetRoot(u.pages, true)
}
// initAddInputModal initialises the add input modal.
func (u *UI) initAddInputModal() {
doneFunc := func(text string, success bool) {
if success {
_ = u.newCard(text, "")
}
u.pages.HidePage(addPageName)
u.setColumnFocus()
}
u.addModal.SetDoneFunc(doneFunc)
}
// initDeleteCardModal initialises the modal for deleting cards.
func (u *UI) initDeleteCardModal() {
doneFunc := func(_ int, buttonLabel string) {
if buttonLabel == "Confirm" {
u.deleteCard()
_ = u.refresh()
}
u.pages.HidePage(deleteCardPageName)
u.setColumnFocus()
}
u.deleteCardModal.SetText("Do you want to delete this card?").
AddButtons([]string{"Confirm", "Cancel"}).
SetDoneFunc(doneFunc)
}
// initQuitModal initialises the quit modal.
func (u *UI) initQuitModal() {
doneFunc := func(_ int, buttonLabel string) {
switch buttonLabel {
case "Quit":
u.shutdown()
default:
u.pages.HidePage(quitPageName)
u.setColumnFocus()
}
}
u.quitModal.SetText("Do you want to quit the application?").
AddButtons([]string{"Quit", "Cancel"}).
SetDoneFunc(doneFunc)
}
// newCard creates and saves a new card to the database.
func (u *UI) newCard(title, content string) error {
args := board.CardArgs{
NewTitle: title,
NewContent: content,
}
if _, err := u.board.CreateCard(args); err != nil {
return fmt.Errorf("unable to create card, %w", err)
}
_ = u.refresh()
return nil
}
// openBoard opens the kanban board.
func (u *UI) openBoard(path string) error {
b, err := board.Open(path)
if err != nil {
return fmt.Errorf("unable to load board, %w", err)
}
u.board = b
if err = u.refresh(); err != nil {
return fmt.Errorf("error refreshing the board, %w", err)
}
return nil
}
// refresh refreshes the UI.
func (u *UI) refresh() error {
statusList, err := u.board.StatusList()
if err != nil {
return fmt.Errorf("unable to get the status list, %w", err)
}
u.updateColumns(statusList)
u.updateMovePage(statusList)
u.setColumnFocus()
return nil
}
// shutdown shuts down the application.
func (u *UI) shutdown() {
u.closeBoard()
u.Stop()
}
func (u *UI) updateMovePage(statusList []board.Status) {
if u.pages.HasPage(movePageName) {
u.pages.RemovePage(movePageName)
}
move := tview.NewFlex()
statusSelection := tview.NewList()
statusSelection.SetBorder(true)
statusSelection.ShowSecondaryText(false)
statusSelection.SetHighlightFullLine(true)
statusSelection.SetSelectedFocusOnly(true)
statusSelection.SetWrapAround(false)
doneFunc := func() {
u.pages.HidePage(movePageName)
u.setColumnFocus()
}
statusSelection.SetDoneFunc(doneFunc)
selectedFunc := func(_ int, _, secondary string, _ rune) {
currentStatusID := u.columns[u.focusedColumn].statusID
nextStatusID, err := strconv.Atoi(secondary)
if err != nil {
nextStatusID = 0
}
currentItem := u.columns[u.focusedColumn].cards.GetCurrentItem()
_, cardIDText := u.columns[u.focusedColumn].cards.GetItemText(currentItem)
cardID, _ := strconv.Atoi(cardIDText)
args := board.MoveToStatusArgs{
CardID: cardID,
CurrentStatusID: currentStatusID,
NextStatusID: nextStatusID,
}
_ = u.board.MoveToStatus(args)
u.pages.HidePage(movePageName)
_ = u.refresh()
}
statusSelection.SetSelectedFunc(selectedFunc)
for _, status := range statusList {
id := strconv.Itoa(status.ID)
statusSelection.AddItem(fmt.Sprintf("\u25C9 %s", status.Name), id, 0, nil)
}
move.AddItem(statusSelection, 0, 1, true)
u.move = move
u.pages.AddPage(movePageName, move, false, false)
}

73
magefiles/magefile.go Normal file
View file

@ -0,0 +1,73 @@
//go:build mage
package main
import (
"os"
"strings"
"github.com/magefile/mage/sh"
)
const (
binary = "pelican"
)
var Default = Build
// Test run the go tests
// To enable verbose mode set CANAL_TEST_VERBOSE=1.
// To enable coverage mode set CANAL_TEST_COVER=1.
func Test() error {
goTest := sh.RunCmd("go", "test")
args := []string{"./..."}
if os.Getenv("PELICAN_TEST_VERBOSE") == "1" {
args = append(args, "-v")
}
if os.Getenv("PELICAN_TEST_COVER") == "1" {
args = append(args, "-cover")
}
return goTest(args...)
}
// Lint runs golangci-lint against the code.
func Lint() error {
return sh.RunV("golangci-lint", "run", "--color", "always")
}
// Build build the executable
func Build() error {
main := "./cmd/"+binary+"/main.go"
return sh.Run("go", "build", "-o", binary, main)
}
// Clean clean the workspace
func Clean() error {
if err := sh.Rm(binary); err != nil {
return err
}
if err := sh.Run("go", "clean", "./..."); err != nil {
return err
}
testDBDir := "./test/databases"
files, err := os.ReadDir(testDBDir)
if err != nil {
return err
}
for _, f := range files {
filename := f.Name()
if strings.HasSuffix(filename, ".db") {
sh.Rm(testDBDir + "/" + filename)
}
}
return nil
}

0
test/databases/.gitkeep Normal file
View file