kanban

Kanban client in Gio
Log | Files | Refs | README | LICENSE

commit 31a605c80b8e0605761e810f246f2a9f7d6bd61f
parent a41d4f9531907e42cc088ba85bbb1a13d150c89c
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Wed,  4 Nov 2020 16:28:14 +0800

feat: use database for persitant storage

Diffstat:
Mcmd/kanban/main.go | 60+++++++++++++++++++++++++++++++++++++++---------------------
Mgo.mod | 1+
Mgo.sum | 38++++++++++++++++++++++++++++++++++++++
Mkanban.go | 161++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
4 files changed, 176 insertions(+), 84 deletions(-)

diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -6,9 +6,12 @@ import ( "image/color" "log" "os" + "path/filepath" + "strconv" "unsafe" "git.sr.ht/~jackmordaunt/kanban" + "github.com/asdine/storm/v3" "gioui.org/f32" "gioui.org/font/gofont" @@ -27,26 +30,41 @@ import ( ) func main() { - w := app.NewWindow(app.Title("Kanban")) - th := material.NewTheme(gofont.Collection()) + db, err := func() (*storm.DB, error) { + path := filepath.Join(os.TempDir(), "kanban.db") + fmt.Printf("%s\n", path) + var init = false + if _, err := os.Stat(path); os.IsNotExist(err) { + init = true + } + db, err := storm.Open(path) + if err != nil { + return nil, fmt.Errorf("opening data file: %w", err) + } + if err := db.Init(&kanban.Stage{}); err != nil { + return nil, err + } + if err := db.ReIndex(&kanban.Stage{}); err != nil { + return nil, err + } + if init { + for ii, stage := range []string{"Todo", "In Progress", "Testing", "Done"} { + if err := db.Save(&kanban.Stage{ID: ii + 1, Name: stage}); err != nil { + return nil, fmt.Errorf("creating default stages: %w", err) + } + } + } + return db, nil + }() + if err != nil { + log.Fatalf("error: initializing data: %v", err) + } + defer db.Close() ui := &UI{ - Window: w, - Th: th, + Window: app.NewWindow(app.Title("Kanban")), + Th: material.NewTheme(gofont.Collection()), Kanban: &kanban.Kanban{ - Stages: []kanban.Stage{ - { - Name: "Todo", - }, - { - Name: "In Progress", - }, - { - Name: "Testing", - }, - { - Name: "Done", - }, - }, + Store: db, }, // TODO: render dynamically from storage. Panels: []Panel{ @@ -143,11 +161,11 @@ func (ui *UI) Update(gtx C) { if ui.TicketForm.Submit.Clicked() { ticket, err := ui.TicketForm.Validate() if err != nil { - fmt.Printf("error: %s", err) + fmt.Printf("error: %s\n", err) return } if err := ui.Kanban.Assign(ui.TicketForm.Stage, ticket); err != nil { - fmt.Printf("error: %s", err) + fmt.Printf("error: assigning ticket: %s\n", err) return } ui.TicketForm = TicketForm{} @@ -171,7 +189,7 @@ func (ui *UI) Layout(gtx C) D { stage, _ := ui.Kanban.Stage(panel.Label) var cards = make([]layout.ListElement, len(stage.Tickets)) for ii, ticket := range stage.Tickets { - id := ticket.ID.String() + id := strconv.Itoa(ticket.ID) cards[ii] = func(gtx C, ii int) D { t := (*Ticket)(ui.TicketStates.Next(id, unsafe.Pointer(&Ticket{}))) t.Ticket = stage.Tickets[ii] diff --git a/go.mod b/go.mod @@ -5,5 +5,6 @@ go 1.15 require ( gioui.org v0.0.0-20201101161300-a55302a49867 git.sr.ht/~whereswaldon/materials v0.0.0-20201101220027-a518d42fd730 + github.com/asdine/storm/v3 v3.2.1 golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925 ) diff --git a/go.sum b/go.sum @@ -5,8 +5,34 @@ gioui.org v0.0.0-20201101161300-a55302a49867/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B git.sr.ht/~whereswaldon/materials v0.0.0-20201101220027-a518d42fd730 h1:pAOILGMbZpo3BzS0NXX41bCd4yhIzlG00fkXE1ItN/E= git.sr.ht/~whereswaldon/materials v0.0.0-20201101220027-a518d42fd730/go.mod h1:kLyZfWzOWp15bbVIiZXdNHBg+lluQq66KnB67d70O0U= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM= +github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM= +github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= +github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac= +github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg= +go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -26,7 +52,10 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191105084925-a882066a44e0 h1:QPlSTtPE2k6PZPasQUbzuK3p9JbS+vMXYVto8g/yrsg= +golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -34,10 +63,19 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/kanban.go b/kanban.go @@ -2,24 +2,33 @@ package kanban import ( "fmt" - "strconv" + + "github.com/asdine/storm/v3" ) // Kanban manipulates the model. type Kanban struct { - Stages []Stage + Store *storm.DB } // ListStages returns a list of stages. func (k Kanban) ListStages() ([]Stage, error) { - return k.Stages, nil + var stages []Stage + if err := k.Store.AllByIndex("ID", &stages); err != nil { + return nil, fmt.Errorf("collecting stage: %w", err) + } + return stages, nil } // NextStage returns the stage that follows the specified one. func (k Kanban) NextStage(current string) (string, error) { - for ii, stage := range k.Stages { - if stage.Name == current && ii < len(k.Stages)-1 { - return k.Stages[ii+1].Name, nil + stages, err := k.ListStages() + if err != nil { + return "", err + } + for ii, stage := range stages { + if stage.Name == current && ii < len(stages)-1 { + return stages[ii+1].Name, nil } } return current, fmt.Errorf("no more stages after: %q", current) @@ -27,9 +36,13 @@ func (k Kanban) NextStage(current string) (string, error) { // NextStage returns the stage that preceeds the specified one. func (k Kanban) PreviousStage(current string) (string, error) { - for ii, stage := range k.Stages { + stages, err := k.ListStages() + if err != nil { + return "", err + } + for ii, stage := range stages { if stage.Name == current && ii > 0 { - return k.Stages[ii-1].Name, nil + return stages[ii-1].Name, nil } } return current, fmt.Errorf("no more stages before: %q", current) @@ -38,51 +51,64 @@ func (k Kanban) PreviousStage(current string) (string, error) { // Stage returns a stage by the given name. // Creates an empty stage if it doesn't exist. func (k *Kanban) Stage(name string) (Stage, error) { - for _, stage := range k.Stages { + stages, err := k.ListStages() + if err != nil { + return Stage{}, err + } + for _, stage := range stages { if stage.Name == name { return stage, nil } } - k.Stages = append(k.Stages, Stage{Name: name}) - return k.Stages[len(k.Stages)-1], nil + id, err := k.nextID() + if err != nil { + return Stage{}, err + } + stage := Stage{ID: id, Name: name} + if err := k.Store.Save(&stage); err != nil { + return Stage{}, err + } + return stage, nil } -func (k *Kanban) Move(stage string, ticket ID) error { - var found *Ticket - for ii := range k.Stages { - stage := k.Stages[ii] - for kk := range stage.Tickets { - t := stage.Tickets[kk] +func (k *Kanban) Move(stage string, ticket int) error { + stages, err := k.ListStages() + if err != nil { + return err + } + for ii := range stages { + s := stages[ii] + for kk := range s.Tickets { + t := s.Tickets[kk] if t.ID == ticket { - found = &t if err := k.Delete(ticket); err != nil { - return err + return fmt.Errorf("deleting: %w", err) } - break + return k.Assign(stage, t) } } } - if found == nil { - return fmt.Errorf("ticket %q does not exist", ticket) - } - return k.Assign(stage, *found) + return fmt.Errorf("ticket %q does not exist", ticket) } // Progress a ticket to the next stage. -func (k *Kanban) Progress(ticket ID) error { +func (k *Kanban) Progress(ticket int) error { stage, err := k.StageFor(ticket) if err != nil { - return err + return fmt.Errorf("finding stage: %w", err) } next, err := k.NextStage(stage) if err != nil { - return err + return fmt.Errorf("loading stage: %w", err) } - return k.Move(next, ticket) + if err := k.Move(next, ticket); err != nil { + return fmt.Errorf("moving ticket: %w", err) + } + return nil } // Regress a ticket to the previous stage. -func (k *Kanban) Regress(ticket ID) error { +func (k *Kanban) Regress(ticket int) error { stage, err := k.StageFor(ticket) if err != nil { return err @@ -94,8 +120,12 @@ func (k *Kanban) Regress(ticket ID) error { return k.Move(next, ticket) } -func (k *Kanban) StageFor(ticket ID) (string, error) { - for _, s := range k.Stages { +func (k *Kanban) StageFor(ticket int) (string, error) { + stages, err := k.ListStages() + if err != nil { + return "", err + } + for _, s := range stages { for _, t := range s.Tickets { if t.ID == ticket { return s.Name, nil @@ -105,77 +135,82 @@ func (k *Kanban) StageFor(ticket ID) (string, error) { return "", fmt.Errorf("ticket %q does not exist", ticket) } -func (k *Kanban) Assign(stage string, ticket Ticket) error { - ticket.ID = k.nextID() - for ii := range k.Stages { - s := &k.Stages[ii] - if s.Name == stage { - s.Tickets = append(s.Tickets, ticket) - return nil - } +func (k *Kanban) Assign(name string, ticket Ticket) error { + id, err := k.nextID() + if err != nil { + return fmt.Errorf("generating ID: %w", err) } - k.Stages = append(k.Stages, Stage{ - Name: stage, - Tickets: []Ticket{ticket}, - }) - return nil + ticket.ID = id + stage, err := k.Stage(name) + if err != nil { + return fmt.Errorf("finding stage: %w", err) + } + stage.Tickets = append(stage.Tickets, ticket) + return k.Store.Update(&stage) } -func (k *Kanban) Delete(ticket ID) error { - for kk := range k.Stages { - s := &k.Stages[kk] +func (k *Kanban) Delete(ticket int) error { + stages, err := k.ListStages() + if err != nil { + return err + } + for kk := range stages { + s := &stages[kk] for ii := range s.Tickets { if s.Tickets[ii].ID == ticket { s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...) - return nil + return k.Store.Update(s) } } } - return nil + return fmt.Errorf("ticket %q does not exist", ticket) } func (k *Kanban) Update(ticket Ticket) error { - for _, s := range k.Stages { + stages, err := k.ListStages() + if err != nil { + return err + } + for _, s := range stages { for ii := range s.Tickets { if s.Tickets[ii].ID == ticket.ID { s.Tickets[ii] = ticket - return nil + return k.Store.Update(&s) } } } - return nil + return fmt.Errorf("ticket %q does not exist", ticket) } -func (k *Kanban) nextID() ID { +func (k *Kanban) nextID() (int, error) { + stages, err := k.ListStages() + if err != nil { + return -1, err + } var max int - for _, stage := range k.Stages { + for _, stage := range stages { for _, t := range stage.Tickets { if int(t.ID) > max { max = int(t.ID) } } } - return ID(max + 1) + return max + 1, nil } -type ID int - // Stage in the kanban pipeline, can hold a number of tickets. type Stage struct { + ID int `storm:"id,index"` Name string Tickets []Ticket } // Ticket in a stage. type Ticket struct { - ID ID + ID int Title string Category string Summary string Details string - References []ID -} - -func (id ID) String() string { - return strconv.Itoa(int(id)) + References []int }