├── .github └── workflows │ ├── build.yml │ └── lint.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmds.go ├── db.go ├── db_test.go ├── go.mod ├── go.sum └── main.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: build 3 | on: push 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | os: 9 | - ubuntu-latest 10 | - macos-latest 11 | - windows-latest 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.20' 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 9 | pull-requests: read 10 | 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: golangci-lint 18 | uses: golangci/golangci-lint-action@v3 19 | with: 20 | # Optional: golangci-lint command line arguments. 21 | args: --issues-exit-code=0 22 | # Optional: show only new issues if it's a pull request. The default value is `false`. 23 | only-new-issues: true 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | task-manager 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Charm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Creating a Task Manager (a Charm Tutorial) 2 | 3 | This project is inspired by the incredible work on Task Warrior, an open source 4 | CLI task manager. I use this project quite a bit for managing my projects 5 | without leaving the safety and comfort of my terminal. (⌐■_■) 6 | 7 | We built a kanban board TUI in a previous [tutorial][kanban-video], so the 8 | idea here is that we're going to build a task management CLI with [Cobra][cobra] that has Lip Gloss 9 | styles *and* can be viewed using our kanban board. 10 | 11 | *Note: We walk through the code explaining each and every piece of it in the 12 | [corresponding video](https://youtu.be/yiFhQGJeRJk) for this tutorial. Enjoy!!* 13 | 14 | Here's the plan: 15 | 16 | ## Checklist 17 | 18 | If you're following along with our tutorials for this project, or even if you 19 | want to try and tackle it yourself first, then look at our solutions, here's 20 | what you need to do: 21 | 22 | ### Data Storage 23 | - [ ] set up a SQLite database 24 | - [ ] open SQLite DB 25 | - [ ] add task 26 | - [ ] delete task 27 | - [ ] edit task 28 | - [ ] get tasks 29 | 30 | ### Making a CLI with Cobra 31 | - [ ] add CLI 32 | - [ ] add task 33 | - [ ] delete task 34 | - [ ] edit task 35 | - [ ] get tasks 36 | 37 | ### Add a little... *Je ne sais quoi* 38 | - [ ] print to table layout with [Lip Gloss][lipgloss] 39 | - [ ] print to Kanban layout with [Lip Gloss][lipgloss] 40 | 41 | ## Project Layout 42 | 43 | `db.go` - here we create our custom `task` struct and our data layer. 44 | 45 | `main.go` - our main file handles our initial setup including opening a 46 | database and setting the data path for our application. 47 | 48 | `cmds.go` - this is where we do all of our Cobra commands and setup for our 49 | CLI. 50 | 51 | [lipgloss]: https://github.com/charmbracelet/lipgloss 52 | [charm]: https://github.com/charmbracelet/charm 53 | [cobra]: https://github.com/spf13/cobra 54 | [kanban-video]: https://www.youtube.com/watch?v=ZA93qgdLUzM&list=PLLLtqOZfy0pcFoSIeGXO-SOaP9qLqd_H6 55 | -------------------------------------------------------------------------------- /cmds.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/kancli" 10 | 11 | "github.com/charmbracelet/bubbles/list" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/charmbracelet/lipgloss/table" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var rootCmd = &cobra.Command{ 18 | Use: "tasks", 19 | Short: "A CLI task management tool for ~slaying~ your to do list.", 20 | Args: cobra.NoArgs, 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | return cmd.Help() 23 | }, 24 | } 25 | 26 | var addCmd = &cobra.Command{ 27 | Use: "add NAME", 28 | Short: "Add a new task with an optional project name", 29 | Args: cobra.ExactArgs(1), 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | t, err := openDB(setupPath()) 32 | if err != nil { 33 | return err 34 | } 35 | defer t.db.Close() 36 | project, err := cmd.Flags().GetString("project") 37 | if err != nil { 38 | return err 39 | } 40 | if err := t.insert(args[0], project); err != nil { 41 | return err 42 | } 43 | return nil 44 | }, 45 | } 46 | 47 | var whereCmd = &cobra.Command{ 48 | Use: "where", 49 | Short: "Show where your tasks are stored", 50 | Args: cobra.NoArgs, 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | _, err := fmt.Println(setupPath()) 53 | return err 54 | }, 55 | } 56 | 57 | var deleteCmd = &cobra.Command{ 58 | Use: "delete ID", 59 | Short: "Delete a task by ID", 60 | Args: cobra.ExactArgs(1), 61 | RunE: func(cmd *cobra.Command, args []string) error { 62 | t, err := openDB(setupPath()) 63 | if err != nil { 64 | return err 65 | } 66 | defer t.db.Close() 67 | id, err := strconv.Atoi(args[0]) 68 | if err != nil { 69 | return err 70 | } 71 | return t.delete(uint(id)) 72 | }, 73 | } 74 | 75 | var updateCmd = &cobra.Command{ 76 | Use: "update ID", 77 | Short: "Update a task by ID", 78 | Args: cobra.ExactArgs(1), 79 | RunE: func(cmd *cobra.Command, args []string) error { 80 | t, err := openDB(setupPath()) 81 | if err != nil { 82 | return err 83 | } 84 | defer t.db.Close() 85 | name, err := cmd.Flags().GetString("name") 86 | if err != nil { 87 | return err 88 | } 89 | project, err := cmd.Flags().GetString("project") 90 | if err != nil { 91 | return err 92 | } 93 | prog, err := cmd.Flags().GetInt("status") 94 | if err != nil { 95 | return err 96 | } 97 | id, err := strconv.Atoi(args[0]) 98 | if err != nil { 99 | return err 100 | } 101 | var status string 102 | switch prog { 103 | case int(inProgress): 104 | status = inProgress.String() 105 | case int(done): 106 | status = done.String() 107 | default: 108 | status = todo.String() 109 | } 110 | newTask := task{uint(id), name, project, status, time.Time{}} 111 | return t.update(newTask) 112 | }, 113 | } 114 | 115 | var listCmd = &cobra.Command{ 116 | Use: "list", 117 | Short: "List all your tasks", 118 | Args: cobra.NoArgs, 119 | RunE: func(cmd *cobra.Command, args []string) error { 120 | t, err := openDB(setupPath()) 121 | if err != nil { 122 | return err 123 | } 124 | defer t.db.Close() 125 | tasks, err := t.getTasks() 126 | if err != nil { 127 | return err 128 | } 129 | fmt.Print(setupTable(tasks)) 130 | return nil 131 | }, 132 | } 133 | 134 | func setupTable(tasks []task) *table.Table { 135 | columns := []string{"ID", "Name", "Project", "Status", "Created At"} 136 | var rows [][]string 137 | for _, task := range tasks { 138 | rows = append(rows, []string{ 139 | fmt.Sprintf("%d", task.ID), 140 | task.Name, 141 | task.Project, 142 | task.Status, 143 | task.Created.Format("2006-01-02"), 144 | }) 145 | } 146 | t := table.New(). 147 | Border(lipgloss.HiddenBorder()). 148 | Headers(columns...). 149 | Rows(rows...). 150 | StyleFunc(func(row, col int) lipgloss.Style { 151 | if row == 0 { 152 | return lipgloss.NewStyle(). 153 | Foreground(lipgloss.Color("212")). 154 | Border(lipgloss.NormalBorder()). 155 | BorderTop(false). 156 | BorderLeft(false). 157 | BorderRight(false). 158 | BorderBottom(true). 159 | Bold(true) 160 | } 161 | if row%2 == 0 { 162 | return lipgloss.NewStyle().Foreground(lipgloss.Color("246")) 163 | } 164 | return lipgloss.NewStyle() 165 | }) 166 | return t 167 | } 168 | 169 | var kanbanCmd = &cobra.Command{ 170 | Use: "kanban", 171 | Short: "Interact with your tasks in a Kanban board.", 172 | Args: cobra.NoArgs, 173 | RunE: func(cmd *cobra.Command, args []string) error { 174 | t, err := openDB(setupPath()) 175 | if err != nil { 176 | return err 177 | } 178 | defer t.db.Close() 179 | todos, err := t.getTasksByStatus(todo.String()) 180 | if err != nil { 181 | return err 182 | } 183 | ipr, err := t.getTasksByStatus(inProgress.String()) 184 | if err != nil { 185 | return err 186 | } 187 | finished, err := t.getTasksByStatus(done.String()) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | todoCol := kancli.NewColumn(tasksToItems(todos), todo, true) 193 | iprCol := kancli.NewColumn(tasksToItems(ipr), inProgress, false) 194 | doneCol := kancli.NewColumn(tasksToItems(finished), done, false) 195 | board := kancli.NewDefaultBoard([]kancli.Column{todoCol, iprCol, doneCol}) 196 | p := tea.NewProgram(board) 197 | _, err = p.Run() 198 | return err 199 | }, 200 | } 201 | 202 | // convert tasks to items for a list 203 | func tasksToItems(tasks []task) []list.Item { 204 | var items []list.Item 205 | for _, t := range tasks { 206 | items = append(items, t) 207 | } 208 | return items 209 | } 210 | 211 | func init() { 212 | addCmd.Flags().StringP( 213 | "project", 214 | "p", 215 | "", 216 | "specify a project for your task", 217 | ) 218 | rootCmd.AddCommand(addCmd) 219 | rootCmd.AddCommand(listCmd) 220 | updateCmd.Flags().StringP( 221 | "name", 222 | "n", 223 | "", 224 | "specify a name for your task", 225 | ) 226 | updateCmd.Flags().StringP( 227 | "project", 228 | "p", 229 | "", 230 | "specify a project for your task", 231 | ) 232 | updateCmd.Flags().IntP( 233 | "status", 234 | "s", 235 | int(todo), 236 | "specify a status for your task", 237 | ) 238 | rootCmd.AddCommand(updateCmd) 239 | rootCmd.AddCommand(whereCmd) 240 | rootCmd.AddCommand(deleteCmd) 241 | rootCmd.AddCommand(kanbanCmd) 242 | } 243 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "time" 9 | ) 10 | 11 | type status int 12 | 13 | const ( 14 | todo status = iota 15 | inProgress 16 | done 17 | ) 18 | 19 | func (s status) String() string { 20 | return [...]string{"todo", "in progress", "done"}[s] 21 | } 22 | 23 | /* 24 | A note on SQL statements: 25 | Make sure you're using parameterized SQL statements to avoid 26 | SQL injections. This format creates prepared statements at run time. 27 | learn more: https://go.dev/doc/database/sql-injection 28 | */ 29 | 30 | // note for reflect: only exported fields of a struct are settable. 31 | type task struct { 32 | ID uint 33 | Name string 34 | Project string 35 | Status string 36 | Created time.Time 37 | } 38 | 39 | // implement list.Item & list.DefaultItem 40 | func (t task) FilterValue() string { 41 | return t.Name 42 | } 43 | 44 | func (t task) Title() string { 45 | return t.Name 46 | } 47 | 48 | func (t task) Description() string { 49 | return t.Project 50 | } 51 | 52 | // implement kancli.Status 53 | func (s status) Next() int { 54 | if s == done { 55 | return int(todo) 56 | } 57 | return int(s + 1) 58 | } 59 | 60 | func (s status) Prev() int { 61 | if s == todo { 62 | return int(done) 63 | } 64 | return int(s - 1) 65 | } 66 | 67 | func (s status) Int() int { 68 | return int(s) 69 | } 70 | 71 | type taskDB struct { 72 | db *sql.DB 73 | dataDir string 74 | } 75 | 76 | func initTaskDir(path string) error { 77 | if _, err := os.Stat(path); err != nil { 78 | if os.IsNotExist(err) { 79 | return os.Mkdir(path, 0o770) 80 | } 81 | return err 82 | } 83 | return nil 84 | } 85 | 86 | func (t *taskDB) tableExists(name string) bool { 87 | if _, err := t.db.Query("SELECT * FROM tasks"); err == nil { 88 | return true 89 | } 90 | return false 91 | } 92 | 93 | func (t *taskDB) createTable() error { 94 | _, err := t.db.Exec(`CREATE TABLE "tasks" ( "id" INTEGER, "name" TEXT NOT NULL, "project" TEXT, "status" TEXT, "created" DATETIME, PRIMARY KEY("id" AUTOINCREMENT))`) 95 | return err 96 | } 97 | 98 | func (t *taskDB) insert(name, project string) error { 99 | // We don't care about the returned values, so we're using Exec. If we 100 | // wanted to reuse these statements, it would be more efficient to use 101 | // prepared statements. Learn more: 102 | // https://go.dev/doc/database/prepared-statements 103 | _, err := t.db.Exec( 104 | "INSERT INTO tasks(name, project, status, created) VALUES( ?, ?, ?, ?)", 105 | name, 106 | project, 107 | todo.String(), 108 | time.Now()) 109 | return err 110 | } 111 | 112 | func (t *taskDB) delete(id uint) error { 113 | _, err := t.db.Exec("DELETE FROM tasks WHERE id = ?", id) 114 | return err 115 | } 116 | 117 | // Update the task in the db. Provide new values for the fields you want to 118 | // change, keep them empty if unchanged. 119 | func (t *taskDB) update(task task) error { 120 | // Get the existing state of the task we want to update. 121 | orig, err := t.getTask(task.ID) 122 | if err != nil { 123 | return err 124 | } 125 | orig.merge(task) 126 | _, err = t.db.Exec( 127 | "UPDATE tasks SET name = ?, project = ?, status = ? WHERE id = ?", 128 | orig.Name, 129 | orig.Project, 130 | orig.Status, 131 | orig.ID) 132 | return err 133 | } 134 | 135 | // merge the changed fields to the original task 136 | func (orig *task) merge(t task) { 137 | uValues := reflect.ValueOf(&t).Elem() 138 | oValues := reflect.ValueOf(orig).Elem() 139 | for i := 0; i < uValues.NumField(); i++ { 140 | uField := uValues.Field(i).Interface() 141 | if oValues.CanSet() { 142 | if v, ok := uField.(int64); ok && uField != 0 { 143 | oValues.Field(i).SetInt(v) 144 | } 145 | if v, ok := uField.(string); ok && uField != "" { 146 | oValues.Field(i).SetString(v) 147 | } 148 | } 149 | } 150 | } 151 | 152 | func (t *taskDB) getTasks() ([]task, error) { 153 | var tasks []task 154 | rows, err := t.db.Query("SELECT * FROM tasks") 155 | if err != nil { 156 | return tasks, fmt.Errorf("unable to get values: %w", err) 157 | } 158 | for rows.Next() { 159 | var task task 160 | err = rows.Scan( 161 | &task.ID, 162 | &task.Name, 163 | &task.Project, 164 | &task.Status, 165 | &task.Created, 166 | ) 167 | if err != nil { 168 | return tasks, err 169 | } 170 | tasks = append(tasks, task) 171 | } 172 | return tasks, err 173 | } 174 | 175 | func (t *taskDB) getTasksByStatus(status string) ([]task, error) { 176 | var tasks []task 177 | rows, err := t.db.Query("SELECT * FROM tasks WHERE status = ?", status) 178 | if err != nil { 179 | return tasks, fmt.Errorf("unable to get values: %w", err) 180 | } 181 | for rows.Next() { 182 | var task task 183 | err = rows.Scan( 184 | &task.ID, 185 | &task.Name, 186 | &task.Project, 187 | &task.Status, 188 | &task.Created, 189 | ) 190 | if err != nil { 191 | return tasks, err 192 | } 193 | tasks = append(tasks, task) 194 | } 195 | return tasks, err 196 | } 197 | 198 | func (t *taskDB) getTask(id uint) (task, error) { 199 | var task task 200 | err := t.db.QueryRow("SELECT * FROM tasks WHERE id = ?", id). 201 | Scan( 202 | &task.ID, 203 | &task.Name, 204 | &task.Project, 205 | &task.Status, 206 | &task.Created, 207 | ) 208 | return task, err 209 | } 210 | -------------------------------------------------------------------------------- /db_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestDelete(t *testing.T) { 13 | tests := []struct { 14 | want task 15 | }{ 16 | { 17 | want: task{ 18 | ID: 1, 19 | Name: "get milk", 20 | Project: "groceries", 21 | Status: "todo", 22 | }, 23 | }, 24 | } 25 | for _, tc := range tests { 26 | t.Run(tc.want.Name, func(t *testing.T) { 27 | tDB := setup() 28 | defer teardown(tDB) 29 | if err := tDB.insert(tc.want.Name, tc.want.Project); err != nil { 30 | t.Fatalf("unable to insert tasks: %v", err) 31 | } 32 | tasks, err := tDB.getTasks() 33 | if err != nil { 34 | t.Fatalf("unable to get tasks: %v", err) 35 | } 36 | tc.want.Created = tasks[0].Created 37 | if !reflect.DeepEqual(tc.want, tasks[0]) { 38 | t.Fatalf("got %v, want %v", tc.want, tasks) 39 | } 40 | if err := tDB.delete(1); err != nil { 41 | t.Fatalf("unable to delete tasks: %v", err) 42 | } 43 | tasks, err = tDB.getTasks() 44 | if err != nil { 45 | t.Fatalf("unable to get tasks: %v", err) 46 | } 47 | if len(tasks) != 0 { 48 | t.Fatalf("expected tasks to be empty, got: %v", tasks) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestGetTask(t *testing.T) { 55 | tests := []struct { 56 | want task 57 | }{ 58 | { 59 | want: task{ 60 | ID: 1, 61 | Name: "get milk", 62 | Project: "groceries", 63 | Status: todo.String(), 64 | }, 65 | }, 66 | } 67 | for _, tc := range tests { 68 | t.Run(tc.want.Name, func(t *testing.T) { 69 | tDB := setup() 70 | defer teardown(tDB) 71 | if err := tDB.insert(tc.want.Name, tc.want.Project); err != nil { 72 | t.Fatalf("we ran into an unexpected error: %v", err) 73 | } 74 | task, err := tDB.getTask(tc.want.ID) 75 | if err != nil { 76 | t.Fatalf("we ran into an unexpected error: %v", err) 77 | } 78 | tc.want.Created = task.Created 79 | if !reflect.DeepEqual(task, tc.want) { 80 | t.Fatalf("got: %#v, want: %#v", task, tc.want) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestUpdate(t *testing.T) { 87 | tests := []struct { 88 | new *task 89 | old *task 90 | want task 91 | }{ 92 | { 93 | new: &task{ 94 | ID: 1, 95 | Name: "strawberries", 96 | Project: "", 97 | Status: "", 98 | }, 99 | old: &task{ 100 | ID: 1, 101 | Name: "get milk", 102 | Project: "groceries", 103 | Status: todo.String(), 104 | }, 105 | want: task{ 106 | ID: 1, 107 | Name: "strawberries", 108 | Project: "groceries", 109 | Status: todo.String(), 110 | }, 111 | }, 112 | } 113 | for _, tc := range tests { 114 | t.Run(tc.new.Name, func(t *testing.T) { 115 | tDB := setup() 116 | defer teardown(tDB) 117 | if err := tDB.insert(tc.old.Name, tc.old.Project); err != nil { 118 | t.Fatalf("we ran into an unexpected error: %v", err) 119 | } 120 | if err := tDB.update(*tc.new); err != nil { 121 | t.Fatalf("we ran into an unexpected error: %v", err) 122 | } 123 | task, err := tDB.getTask(tc.want.ID) 124 | if err != nil { 125 | t.Fatalf("we ran into an unexpected error: %v", err) 126 | } 127 | tc.want.Created = task.Created 128 | if !reflect.DeepEqual(task, tc.want) { 129 | t.Fatalf("got: %#v, want: %#v", task, tc.want) 130 | } 131 | }) 132 | } 133 | } 134 | 135 | func TestMerge(t *testing.T) { 136 | tests := []struct { 137 | new task 138 | old task 139 | want task 140 | }{ 141 | { 142 | new: task{ 143 | ID: 1, 144 | Name: "strawberries", 145 | Project: "", 146 | Status: "", 147 | }, 148 | old: task{ 149 | ID: 1, 150 | Name: "get milk", 151 | Project: "groceries", 152 | Status: todo.String(), 153 | }, 154 | want: task{ 155 | ID: 1, 156 | Name: "strawberries", 157 | Project: "groceries", 158 | Status: todo.String(), 159 | }, 160 | }, 161 | } 162 | for _, tc := range tests { 163 | tc.old.merge(tc.new) 164 | if !reflect.DeepEqual(tc.old, tc.want) { 165 | t.Fatalf("got: %#v, want %#v", tc.new, tc.want) 166 | } 167 | } 168 | } 169 | 170 | func TestGetTasksByStatus(t *testing.T) { 171 | tests := []struct { 172 | want task 173 | }{ 174 | { 175 | want: task{ 176 | ID: 1, 177 | Name: "get milk", 178 | Project: "groceries", 179 | Status: todo.String(), 180 | }, 181 | }, 182 | } 183 | for _, tc := range tests { 184 | t.Run(tc.want.Name, func(t *testing.T) { 185 | tDB := setup() 186 | defer teardown(tDB) 187 | if err := tDB.insert(tc.want.Name, tc.want.Project); err != nil { 188 | t.Fatalf("we ran into an unexpected error: %v", err) 189 | } 190 | tasks, err := tDB.getTasksByStatus(tc.want.Status) 191 | if err != nil { 192 | t.Fatalf("we ran into an unexpected error: %v", err) 193 | } 194 | if len(tasks) < 1 { 195 | t.Fatalf("expected 1 value, got %#v", tasks) 196 | } 197 | tc.want.Created = tasks[0].Created 198 | if !reflect.DeepEqual(tasks[0], tc.want) { 199 | t.Fatalf("got: %#v, want: %#v", tasks, tc.want) 200 | } 201 | }) 202 | } 203 | } 204 | 205 | func setup() *taskDB { 206 | path := filepath.Join(os.TempDir(), "test.db") 207 | db, err := sql.Open("sqlite3", path) 208 | if err != nil { 209 | log.Fatal(err) 210 | } 211 | t := taskDB{db, path} 212 | if !t.tableExists("tasks") { 213 | err := t.createTable() 214 | if err != nil { 215 | log.Fatal(err) 216 | } 217 | } 218 | return &t 219 | } 220 | 221 | func teardown(tDB *taskDB) { 222 | tDB.db.Close() 223 | os.Remove(tDB.dataDir) 224 | } 225 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module tm 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.16.1 7 | github.com/charmbracelet/bubbletea v0.24.2 8 | github.com/charmbracelet/kancli v0.0.0-20230629174247-b2093471047b 9 | github.com/charmbracelet/lipgloss v0.9.1 10 | github.com/mattn/go-sqlite3 v1.14.17 11 | github.com/muesli/go-app-paths v0.2.2 12 | github.com/spf13/cobra v1.7.0 13 | ) 14 | 15 | require ( 16 | github.com/atotto/clipboard v0.1.4 // indirect 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 19 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 20 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/mattn/go-localereader v0.0.1 // indirect 23 | github.com/mattn/go-runewidth v0.0.15 // indirect 24 | github.com/mitchellh/go-homedir v1.1.0 // indirect 25 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 26 | github.com/muesli/cancelreader v0.2.2 // indirect 27 | github.com/muesli/reflow v0.3.0 // indirect 28 | github.com/muesli/termenv v0.15.2 // indirect 29 | github.com/rivo/uniseg v0.4.4 // indirect 30 | github.com/sahilm/fuzzy v0.1.0 // indirect 31 | github.com/spf13/pflag v1.0.5 // indirect 32 | golang.org/x/sync v0.3.0 // indirect 33 | golang.org/x/sys v0.16.0 // indirect 34 | golang.org/x/term v0.9.0 // indirect 35 | golang.org/x/text v0.10.0 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= 6 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 7 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 8 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 9 | github.com/charmbracelet/kancli v0.0.0-20230629174247-b2093471047b h1:ElLjniv+gMcqCK+RyqDnquzp+CP9uslGjfyJKABSLfU= 10 | github.com/charmbracelet/kancli v0.0.0-20230629174247-b2093471047b/go.mod h1:hq6p8QuwQr/Fsj1nZou17b0taqWI09Nz8GvzVBNaW4s= 11 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 12 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 13 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 14 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 16 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 17 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 18 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 24 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 25 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 26 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 27 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 28 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= 29 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 30 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 31 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 32 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 33 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 34 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 35 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 36 | github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= 37 | github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= 38 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 39 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 40 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 41 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 42 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 43 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 44 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 45 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 46 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 47 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 48 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 49 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 50 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 51 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 52 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 53 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 54 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 55 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 58 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 59 | golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= 60 | golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= 61 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= 62 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 63 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | _ "github.com/mattn/go-sqlite3" 11 | gap "github.com/muesli/go-app-paths" 12 | ) 13 | 14 | /* 15 | Here's the plan, we're going to store our data in a dedicated data directory at 16 | `XDG_DATA_HOME/.tasks`. That's where we will store a copy of our SQLite DB. 17 | */ 18 | 19 | // setupPath uses XDG to create the necessary data dirs for the program. 20 | func setupPath() string { 21 | // get XDG paths 22 | scope := gap.NewScope(gap.User, "tasks") 23 | dirs, err := scope.DataDirs() 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | // create the app base dir, if it doesn't exist 28 | var taskDir string 29 | if len(dirs) > 0 { 30 | taskDir = dirs[0] 31 | } else { 32 | taskDir, _ = os.UserHomeDir() 33 | } 34 | if err := initTaskDir(taskDir); err != nil { 35 | log.Fatal(err) 36 | } 37 | return taskDir 38 | } 39 | 40 | // openDB opens a SQLite database and stores that database in our special spot. 41 | func openDB(path string) (*taskDB, error) { 42 | db, err := sql.Open("sqlite3", filepath.Join(path, "tasks.db")) 43 | if err != nil { 44 | return nil, err 45 | } 46 | t := taskDB{db, path} 47 | if !t.tableExists("tasks") { 48 | err := t.createTable() 49 | if err != nil { 50 | return nil, err 51 | } 52 | } 53 | return &t, nil 54 | } 55 | 56 | func main() { 57 | if err := rootCmd.Execute(); err != nil { 58 | fmt.Println(err) 59 | os.Exit(1) 60 | } 61 | } 62 | --------------------------------------------------------------------------------