commit ceeb4b1100656117d3827dd80520d2c626e96dcb
parent 40391070a55f0cc94dfce699f34739fdd0153822
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date: Sun, 11 Apr 2021 14:14:52 +0800
feat(storage): add project archive semantics
Projects can now be archived. An archived project doesn't show up on standard queries.
Archival means: 'move this data to cold storage so that I don't have to see it, but keep it around in case I want to restore it.'
Diffstat:
7 files changed, 255 insertions(+), 104 deletions(-)
diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go
@@ -14,8 +14,7 @@ import (
"gioui.org/unit"
"gioui.org/widget/material"
"git.sr.ht/~jackmordaunt/kanban/storage"
- "git.sr.ht/~jackmordaunt/kanban/storage/lazy"
- "git.sr.ht/~jackmordaunt/kanban/storage/mem"
+ "git.sr.ht/~jackmordaunt/kanban/storage/bolt"
"gioui.org/app"
)
@@ -36,17 +35,13 @@ func main() {
defer stopper.Stop()
}
storage, err := func() (storage.Storer, error) {
- if MemStorage {
- return mem.New(), nil
- } else {
- data, err := app.DataDir()
- if err != nil {
- return nil, fmt.Errorf("data dir: %v", err)
- }
- db := filepath.Join(data, "kanban.db")
- fmt.Printf("%s\n", db)
- return lazy.Open(db)
+ data, err := app.DataDir()
+ if err != nil {
+ return nil, fmt.Errorf("data dir: %v", err)
}
+ db := filepath.Join(data, "kanban.db")
+ fmt.Printf("%s\n", db)
+ return bolt.Open(db)
}()
if err != nil {
log.Fatalf("storage driver: %v\n", err)
diff --git a/cmd/kanban/ui.go b/cmd/kanban/ui.go
@@ -81,10 +81,11 @@ type UI struct {
Modal layout.Widget
// Form state.
- TicketForm TicketForm
- TicketDetails TicketDetails
- DeleteDialog DeleteDialog
- ProjectForm ProjectForm
+ TicketForm TicketForm
+ TicketDetails TicketDetails
+ DeleteDialog DeleteDialog
+ ProjectForm ProjectForm
+ ArchiveProjectConfirmation ArchiveProjectConfirmation
// FocusedTicket struct {
// ID kanban.ID
@@ -274,6 +275,24 @@ func (ui *UI) Update(gtx C) {
if ui.EditProjectBtn.Clicked() {
ui.EditProject()
}
+ if ui.ProjectForm.Delete.Button.Clicked() {
+ ui.ShowDeleteProjectConfirmation()
+ }
+ if ui.ArchiveProjectConfirmation.SubmitBtn.Clicked() {
+ if ui.ArchiveProjectConfirmation.Confirmation.Text() == ui.Project.Name {
+ if err := ui.Storage.Archive(ui.Project.ID); err != nil {
+ log.Printf("error: archiving project: %v", err)
+ }
+ ui.Save()
+ if len(ui.Projects) > 0 {
+ ui.Project = &ui.Projects[0]
+ }
+ ui.Clear()
+ }
+ }
+ if ui.ArchiveProjectConfirmation.CancelBtn.Clicked() {
+ ui.Clear()
+ }
}
// Layout UI.
@@ -452,56 +471,7 @@ const (
// Refocus to the ticket in the given direction.
// Allows movement between tickets and stages in sequential order.
-func (ui *UI) Refocus(d Direction) {
- // var (
- // project kanban.Project
- // stage kanban.Stage
- // )
- // if err := ui.Project.Find("ID", ui.ActiveProject, &project); err != nil {
- // log.Printf("error: %v", err)
- // return
- // }
- // if err := ui.Project.Find("ID", projet.St)
-
- // for {
- // switch d {
- // case NextTicket:
- // ui.FocusedTicket.Index++
- // if ui.FocusedTicket.Index > len(stages[ui.FocusedTicket.Stage].Tickets) {
- // ui.FocusedTicket.Index = 1
- // ui.FocusedTicket.Stage++
- // if ui.FocusedTicket.Stage > len(stages)-1 {
- // ui.FocusedTicket.Stage = 0
- // }
- // }
- // case PreviousTicket:
- // ui.FocusedTicket.Index--
- // if ui.FocusedTicket.Index < 1 {
- // ui.FocusedTicket.Stage--
- // if ui.FocusedTicket.Stage < 0 {
- // ui.FocusedTicket.Stage = len(stages) - 1
- // }
- // ui.FocusedTicket.Index = len(stages[ui.FocusedTicket.Stage].Tickets)
- // }
- // case NextStage:
- // ui.FocusedTicket.Index = 1
- // ui.FocusedTicket.Stage++
- // if ui.FocusedTicket.Stage > len(stages)-1 {
- // ui.FocusedTicket.Stage = 0
- // }
- // case PreviousStage:
- // ui.FocusedTicket.Index = 1
- // ui.FocusedTicket.Stage--
- // if ui.FocusedTicket.Stage < 0 {
- // ui.FocusedTicket.Stage = len(stages) - 1
- // }
- // }
- // if stage := stages[ui.FocusedTicket.Stage]; !stage.Empty() {
- // break
- // }
- // }
- // ui.FocusedTicket.ID = stages[ui.FocusedTicket.Stage].Tickets[ui.FocusedTicket.Index-1].ID
-}
+func (ui *UI) Refocus(d Direction) {}
// Clear resets navigational state.
func (ui *UI) Clear() {
@@ -509,12 +479,7 @@ func (ui *UI) Clear() {
ui.TicketForm = TicketForm{}
ui.ProjectForm = ProjectForm{}
ui.DeleteDialog = DeleteDialog{}
- // @cleanup
- // ui.FocusedTicket = struct {
- // ID kanban.ID
- // Index int
- // Stage kanban.ID
- // }{}
+ ui.ArchiveProjectConfirmation = ArchiveProjectConfirmation{}
}
// InspectTicket opens the ticket details card for the given ticket.
@@ -569,6 +534,15 @@ func (ui *UI) EditProject() {
}
}
+func (ui *UI) ShowDeleteProjectConfirmation() {
+ if ui.Project == nil {
+ return
+ }
+ ui.Modal = func(gtx C) D {
+ return ui.ArchiveProjectConfirmation.Layout(gtx, ui.Th)
+ }
+}
+
// Projects is a list of Project entities with added behaviours.
type Projects []kanban.Project
@@ -595,6 +569,12 @@ func (ui *UI) Save() {
if err := ui.Storage.Save(ui.Projects...); err != nil {
log.Printf("error: saving projects: %v", err)
}
+ // Remove any zeroed out projects because they don't exist anymore.
+ for ii, p := range ui.Projects {
+ if p.ID == uuid.Nil {
+ ui.Projects = append(ui.Projects[:ii], ui.Projects[ii+1:]...)
+ }
+ }
}
// sync any project dependent state when a project has changed.
diff --git a/cmd/kanban/widgets.go b/cmd/kanban/widgets.go
@@ -110,7 +110,10 @@ func (f *TicketForm) Layout(gtx C, th *material.Theme, stage string) D {
// ProjectForm renders a form for manipulating projects.
type ProjectForm struct {
*kanban.Project
- Name component.TextField
+ Name component.TextField
+ Delete struct {
+ Button widget.Clickable
+ }
SubmitBtn widget.Clickable
CancelBtn widget.Clickable
}
@@ -127,7 +130,7 @@ func (f *ProjectForm) Submit() {
}
func (f *ProjectForm) Mode() Mode {
- if f.Project.ID != uuid.Nil {
+ if f.Project != nil {
return ModeEdit
}
return ModeCreate
@@ -148,6 +151,14 @@ func (f *ProjectForm) Layout(gtx C, th *material.Theme) D {
layout.Rigid(func(gtx C) D {
return f.Name.Layout(gtx, th, "Project Name")
}),
+ layout.Rigid(func(gtx C) D {
+ if f.Mode() != ModeEdit {
+ return D{}
+ }
+ btn := material.Button(th, &f.Delete.Button, "Archive")
+ btn.Background = color.NRGBA{R: 200, A: 200}
+ return btn.Layout(gtx)
+ }),
)
},
Actions: []control.Action{
@@ -459,3 +470,43 @@ func (t *TicketDetails) Layout(gtx C, th *material.Theme) D {
},
}.Layout(gtx, th)
}
+
+// ArchiveProjectConfirmation displays a dialog prompting the user to confirm
+// the name of the project to confirm the archival.
+// This is a safety step to avoid accidentally archiving a project.
+type ArchiveProjectConfirmation struct {
+ Confirmation component.TextField
+ SubmitBtn widget.Clickable
+ CancelBtn widget.Clickable
+}
+
+func (dpc *ArchiveProjectConfirmation) Layout(gtx C, th *material.Theme) D {
+ return control.Card{
+ Title: "Archive Project",
+ Body: func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ Alignment: layout.Middle,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return dpc.Confirmation.Layout(gtx, th, "Project")
+ }),
+ )
+ },
+ Actions: []control.Action{
+ {
+ Clickable: &dpc.SubmitBtn,
+ Label: "Archive",
+ Fg: th.ContrastFg,
+ Bg: color.NRGBA{R: 200, A: 255},
+ },
+ {
+ Clickable: &dpc.CancelBtn,
+ Label: "Cancel",
+ Fg: th.Fg,
+ Bg: th.Bg,
+ },
+ },
+ }.Layout(gtx, th)
+}
diff --git a/storage/bolt/bolt.go b/storage/bolt/bolt.go
@@ -24,6 +24,7 @@ func (b Bucket) String() string {
var (
BucketProject Bucket = Bucket("Project")
+ BucketArchive Bucket = Bucket("Archive")
)
func Open(path string) (*Storer, error) {
@@ -35,6 +36,9 @@ func Open(path string) (*Storer, error) {
if _, err := tx.CreateBucketIfNotExists(BucketProject); err != nil {
return err
}
+ if _, err := tx.CreateBucketIfNotExists(BucketArchive); err != nil {
+ return err
+ }
return nil
}); err != nil {
return nil, fmt.Errorf("initializing buckets: %w", err)
@@ -63,9 +67,13 @@ func (db *Storer) Create(p kanban.Project) error {
})
}
+// Save persists the provided projects.
+// If a project is nil, or doesn't exist in the store, it will be zeroed out
+// in the slice.
+// The caller can then cleanup the slice by removing the zeroed out projects.
func (db *Storer) Save(projects ...kanban.Project) error {
return db.Update(func(tx *bolt.Tx) error {
- for _, p := range projects {
+ for ii, p := range projects {
if p.ID == uuid.Nil {
continue
}
@@ -77,8 +85,14 @@ func (db *Storer) Save(projects ...kanban.Project) error {
if err != nil {
return fmt.Errorf("serializing project: %w", err)
}
- if err := tx.Bucket(BucketProject).Put(id, v); err != nil {
- return fmt.Errorf("updating project: %w", err)
+ if b := tx.Bucket(BucketProject); b != nil {
+ if b.Get(id) == nil {
+ projects[ii] = kanban.Project{}
+ continue
+ }
+ if err := b.Put(id, v); err != nil {
+ return fmt.Errorf("updating project: %w", err)
+ }
}
}
return nil
@@ -99,18 +113,8 @@ func (db *Storer) Find(id uuid.UUID) (p kanban.Project, ok bool, err error) {
})
}
-func (db *Storer) List() (list []kanban.Project, err error) {
- return list, db.View(func(tx *bolt.Tx) error {
- c := tx.Bucket(BucketProject).Cursor()
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var p kanban.Project
- if err := json.Unmarshal(v, &p); err != nil {
- return fmt.Errorf("deserializing project: %w", err)
- }
- list = append(list, p)
- }
- return nil
- })
+func (db *Storer) List() ([]kanban.Project, error) {
+ return db.list(BucketProject)
}
func (db *Storer) Load(projects []kanban.Project) error {
@@ -147,3 +151,50 @@ func (db *Storer) Count() (count int, err error) {
return nil
})
}
+
+func (db *Storer) Archive(id uuid.UUID) error {
+ return db.move(id, BucketProject, BucketArchive)
+}
+
+func (db *Storer) Restore(id uuid.UUID) error {
+ return db.move(id, BucketArchive, BucketProject)
+}
+
+func (db *Storer) ListArchived() ([]kanban.Project, error) {
+ return db.list(BucketArchive)
+}
+
+func (db *Storer) move(id uuid.UUID, from, to Bucket) error {
+ k, err := id.MarshalBinary()
+ if err != nil {
+ return fmt.Errorf("serializing ID: %w", err)
+ }
+ return db.Update(func(tx *bolt.Tx) error {
+ fromBucket := tx.Bucket(from)
+ v := fromBucket.Get(k)
+ if v == nil {
+ return fmt.Errorf("project does not exist in %q bucket: %q", from, id)
+ }
+ if err := fromBucket.Delete(k); err != nil {
+ return fmt.Errorf("deleting project from %q bucket: %w", from, err)
+ }
+ if err := tx.Bucket(to).Put(k, v); err != nil {
+ return fmt.Errorf("placing project in %q bucket: %w", to, err)
+ }
+ return nil
+ })
+}
+
+func (db *Storer) list(from Bucket) (list []kanban.Project, err error) {
+ return list, db.View(func(tx *bolt.Tx) error {
+ c := tx.Bucket(from).Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ var p kanban.Project
+ if err := json.Unmarshal(v, &p); err != nil {
+ return fmt.Errorf("deserializing project: %w", err)
+ }
+ list = append(list, p)
+ }
+ return nil
+ })
+}
diff --git a/storage/lazy/lazy.go b/storage/lazy/lazy.go
@@ -28,20 +28,20 @@ func Open(path string) (*Storer, error) {
if err != nil {
return nil, err
}
- s := &Storer{
+ s := Storer{
Cache: mem.New(),
Disk: disk,
}
- return s, s.Populate()
+ return &s, s.Populate()
}
// Create a project. Saves to disk.
func (s *Storer) Create(p kanban.Project) error {
if err := s.Cache.Create(p); err != nil {
- return fmt.Errorf("creating on disk: %v", err)
+ return fmt.Errorf("creating on disk: %w", err)
}
if err := s.Disk.Create(p); err != nil {
- return fmt.Errorf("creating on disk: %v", err)
+ return fmt.Errorf("creating on disk: %w", err)
}
return nil
}
@@ -58,7 +58,7 @@ func (s *Storer) Save(projects ...kanban.Project) error {
}
if !reflect.DeepEqual(p, old) {
if err := s.Disk.Save(p); err != nil {
- return fmt.Errorf("saving to disk: %v", err)
+ return fmt.Errorf("saving to disk: %w", err)
}
return s.Refresh(p.ID)
}
@@ -74,14 +74,14 @@ func (s *Storer) Find(id uuid.UUID) (kanban.Project, bool, error) {
// List projects.
func (s *Storer) List() ([]kanban.Project, error) {
- return s.Disk.List()
+ return s.Cache.List()
}
// Refresh a project entity by loading from disk.
func (s *Storer) Refresh(id uuid.UUID) error {
p, ok, err := s.Disk.Find(id)
if err != nil {
- return fmt.Errorf("loading from disk: %v", err)
+ return fmt.Errorf("loading from disk: %w", err)
}
if !ok {
return fmt.Errorf("project does not exist: %v", id)
@@ -93,18 +93,35 @@ func (s *Storer) Refresh(id uuid.UUID) error {
func (s *Storer) Populate() error {
projects, err := s.Disk.List()
if err != nil {
- return fmt.Errorf("loading projects from disk: %v", err)
+ return fmt.Errorf("loading projects from disk: %w", err)
}
for _, p := range projects {
if err := s.Cache.Create(p); err != nil {
- return fmt.Errorf("saving project to cache: %v", err)
+ return fmt.Errorf("saving project to cache: %w", err)
+ }
+ }
+ archived, err := s.Disk.ListArchived()
+ if err != nil {
+ return fmt.Errorf("loading archived projects from disk: %w", err)
+ }
+ for _, p := range archived {
+ if err := func() error {
+ if err := s.Cache.Create(p); err != nil {
+ return err
+ }
+ if err := s.Cache.Archive(p.ID); err != nil {
+ return err
+ }
+ return nil
+ }(); err != nil {
+ return fmt.Errorf("saving archived projects to cache: %w", err)
}
}
return nil
}
func (s *Storer) Load(projects []kanban.Project) error {
- return s.Disk.Load(projects)
+ return s.Cache.Load(projects)
}
func (s *Storer) Close() error {
@@ -114,3 +131,21 @@ func (s *Storer) Close() error {
func (s *Storer) Count() (int, error) {
return s.Cache.Count()
}
+
+func (s *Storer) Archive(id uuid.UUID) error {
+ if err := s.Disk.Archive(id); err != nil {
+ return err
+ }
+ return s.Populate()
+}
+
+func (s *Storer) Restore(id uuid.UUID) error {
+ if err := s.Disk.Restore(id); err != nil {
+ return err
+ }
+ return s.Populate()
+}
+
+func (s *Storer) ListArchived() ([]kanban.Project, error) {
+ return s.Cache.ListArchived()
+}
diff --git a/storage/mem/mem.go b/storage/mem/mem.go
@@ -3,23 +3,25 @@ package mem
import (
"fmt"
- "git.sr.ht/~jackmordaunt/kanban/storage"
"github.com/google/uuid"
"git.sr.ht/~jackmordaunt/kanban"
)
-var _ storage.Storer = (*Storer)(nil)
+// var _ storage.Storer = (*Storer)(nil)
// Storer implements in-memory storage for Projects.
type Storer struct {
- Data map[uuid.UUID]kanban.Project
- Order []uuid.UUID
+ Data map[uuid.UUID]kanban.Project
+ Order []uuid.UUID
+ Archived map[uuid.UUID]kanban.Project
+ ArchivedOrder []uuid.UUID
}
func New() *Storer {
return &Storer{
- Data: make(map[uuid.UUID]kanban.Project),
+ Data: make(map[uuid.UUID]kanban.Project),
+ Archived: make(map[uuid.UUID]kanban.Project),
}
}
@@ -72,3 +74,32 @@ func (s *Storer) Load(projects []kanban.Project) error {
}
return nil
}
+
+func (s *Storer) Archive(id uuid.UUID) error {
+ p, ok := s.Data[id]
+ if !ok {
+ return nil
+ }
+ delete(s.Data, id)
+ s.Archived[id] = p
+ return nil
+}
+
+func (s *Storer) Restore(id uuid.UUID) error {
+ p, ok := s.Archived[id]
+ if !ok {
+ return nil
+ }
+ delete(s.Archived, id)
+ s.Data[id] = p
+ return nil
+}
+
+func (s *Storer) ListArchived() (list []kanban.Project, err error) {
+ for _, id := range s.ArchivedOrder {
+ if p, ok := s.Archived[id]; ok {
+ list = append(list, p)
+ }
+ }
+ return list, nil
+}
diff --git a/storage/storage.go b/storage/storage.go
@@ -23,4 +23,12 @@ type Storer interface {
List() ([]kanban.Project, error)
// Count returns the number of projects that exist in the store.
Count() (int, error)
+ // Archive a project.
+ // An archived project will have it's data saved, but won't show up under
+ // normal queries.
+ Archive(uuid.UUID) error
+ // ListArchived lists all archived projects.
+ ListArchived() ([]kanban.Project, error)
+ // Restore takes an archived project and makes it live again.
+ Restore(uuid.UUID) error
}