diff --git a/cmd/cli/command/done.go b/cmd/cli/command/done.go index 9bbedae..6bccf19 100644 --- a/cmd/cli/command/done.go +++ b/cmd/cli/command/done.go @@ -1,6 +1,8 @@ package command import ( + "fmt" + "git.ewintr.nl/gte/cmd/cli/format" "git.ewintr.nl/gte/internal/configuration" "git.ewintr.nl/gte/internal/process" @@ -21,8 +23,22 @@ func NewDone(conf *configuration.Configuration, cmdArgs []string) (*Done, error) disp := storage.NewDispatcher(msend.NewSSLSMTP(conf.SMTP())) fields := process.UpdateFields{"done": "true"} + localIds, err := local.LocalIds() + if err != nil { + return &Done{}, err + } + var tId string + for id, localId := range localIds { + if fmt.Sprintf("%d", localId) == cmdArgs[0] { + tId = id + break + } + } + if tId == "" { + return &Done{}, fmt.Errorf("could not find task") + } - updater := process.NewUpdate(local, disp, cmdArgs[0], fields) + updater := process.NewUpdate(local, disp, tId, fields) return &Done{ doner: updater, diff --git a/cmd/cli/command/today.go b/cmd/cli/command/today.go index e2cd742..a1f81f1 100644 --- a/cmd/cli/command/today.go +++ b/cmd/cli/command/today.go @@ -10,6 +10,7 @@ import ( // Today lists all task that are due today or past their due date type Today struct { + local storage.LocalRepository todayer *process.List } @@ -25,6 +26,7 @@ func NewToday(conf *configuration.Configuration) (*Today, error) { todayer := process.NewList(local, reqs) return &Today{ + local: local, todayer: todayer, }, nil } @@ -38,5 +40,5 @@ func (t *Today) Do() string { return "nothing left\n" } - return format.FormatTaskTable(res.Tasks) + return format.FormatTaskTable(t.local, res.Tasks) } diff --git a/cmd/cli/command/tomorrow.go b/cmd/cli/command/tomorrow.go index d132274..13a9e4b 100644 --- a/cmd/cli/command/tomorrow.go +++ b/cmd/cli/command/tomorrow.go @@ -10,6 +10,7 @@ import ( // Tomorrow lists all tasks that are due tomorrow type Tomorrow struct { + local storage.LocalRepository tomorrower *process.List } @@ -25,6 +26,7 @@ func NewTomorrow(conf *configuration.Configuration) (*Tomorrow, error) { tomorrower := process.NewList(local, reqs) return &Tomorrow{ + local: local, tomorrower: tomorrower, }, nil } @@ -39,5 +41,5 @@ func (t *Tomorrow) Do() string { return "nothing to do tomorrow\n" } - return format.FormatTaskTable(res.Tasks) + return format.FormatTaskTable(t.local, res.Tasks) } diff --git a/cmd/cli/format/format.go b/cmd/cli/format/format.go index 32a7d29..762eb2f 100644 --- a/cmd/cli/format/format.go +++ b/cmd/cli/format/format.go @@ -3,6 +3,7 @@ package format import ( "fmt" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" ) @@ -10,10 +11,19 @@ func FormatError(err error) string { return fmt.Sprintf("could not perform command.\n\nerror: %s\n", err.Error()) } -func FormatTaskTable(tasks []*task.Task) string { +func FormatTaskTable(local storage.LocalRepository, tasks []*task.Task) string { + if len(tasks) == 0 { + return "no tasks to display\n" + } + + localIds, err := local.LocalIds() + if err != nil { + return FormatError(err) + } + var output string for _, t := range tasks { - output += fmt.Sprintf("%s\t%s\t%s\n", t.Id, t.Due.String(), t.Action) + output += fmt.Sprintf("%d\t%s\t%s\n", localIds[t.Id], t.Due.String(), t.Action) } return output diff --git a/internal/storage/local.go b/internal/storage/local.go index 0684696..5f92946 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -17,4 +17,41 @@ type LocalRepository interface { FindAllInFolder(folder string) ([]*task.Task, error) FindAllInProject(project string) ([]*task.Task, error) FindById(id string) (*task.Task, error) + FindByLocalId(id int) (*task.Task, error) + LocalIds() (map[string]int, error) +} + +func NextLocalId(used []int) int { + if len(used) == 0 { + return 1 + } + + usedMax := 1 + for _, u := range used { + if u > usedMax { + usedMax = u + } + } + + var limit int + for limit = 1; limit <= len(used) || limit < usedMax; limit *= 10 { + } + + newId := used[len(used)-1] + 1 + if newId < limit { + return newId + } + + usedMap := map[int]bool{} + for _, u := range used { + usedMap[u] = true + } + + for i := 1; i < limit; i++ { + if _, ok := usedMap[i]; !ok { + return i + } + } + + return limit } diff --git a/internal/storage/local_test.go b/internal/storage/local_test.go new file mode 100644 index 0000000..030b7a3 --- /dev/null +++ b/internal/storage/local_test.go @@ -0,0 +1,66 @@ +package storage_test + +import ( + "testing" + + "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/internal/storage" +) + +func TestNextLocalId(t *testing.T) { + for _, tc := range []struct { + name string + used []int + exp int + }{ + { + name: "empty", + used: []int{}, + exp: 1, + }, + { + name: "not empty", + used: []int{5}, + exp: 6, + }, + { + name: "multiple", + used: []int{2, 3, 4}, + exp: 5, + }, + { + name: "holes", + used: []int{1, 5, 8}, + exp: 9, + }, + { + name: "expand limit", + used: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + exp: 11, + }, + { + name: "wrap if possible", + used: []int{8, 9}, + exp: 1, + }, + { + name: "find hole", + used: []int{1, 2, 3, 4, 5, 7, 8, 9}, + exp: 6, + }, + { + name: "dont wrap if expanded before", + used: []int{15, 16}, + exp: 17, + }, + { + name: "do wrap if expanded limit is reached", + used: []int{99}, + exp: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + test.Equals(t, tc.exp, storage.NextLocalId(tc.used)) + }) + } +} diff --git a/internal/storage/memory.go b/internal/storage/memory.go index b52bdbb..066a8eb 100644 --- a/internal/storage/memory.go +++ b/internal/storage/memory.go @@ -10,11 +10,13 @@ import ( type Memory struct { tasks []*task.Task latestSync time.Time + localIds map[string]int } func NewMemory() *Memory { return &Memory{ - tasks: []*task.Task{}, + tasks: []*task.Task{}, + localIds: map[string]int{}, } } @@ -28,6 +30,7 @@ func (m *Memory) SetTasks(tasks []*task.Task) error { nt := *t nt.Message = nil nTasks = append(nTasks, &nt) + m.setLocalId(t.Id) } m.tasks = nTasks m.latestSync = time.Now() @@ -35,6 +38,16 @@ func (m *Memory) SetTasks(tasks []*task.Task) error { return nil } +func (m *Memory) setLocalId(id string) { + used := []int{} + for _, id := range m.localIds { + used = append(used, id) + } + + next := NextLocalId(used) + m.localIds[id] = next +} + func (m *Memory) FindAllInFolder(folder string) ([]*task.Task, error) { tasks := []*task.Task{} for _, t := range m.tasks { @@ -67,3 +80,17 @@ func (m *Memory) FindById(id string) (*task.Task, error) { return &task.Task{}, ErrTaskNotFound } + +func (m *Memory) FindByLocalId(localId int) (*task.Task, error) { + for _, t := range m.tasks { + if m.localIds[t.Id] == localId { + return t, nil + } + } + + return &task.Task{}, ErrTaskNotFound +} + +func (m *Memory) LocalIds() (map[string]int, error) { + return m.localIds, nil +} diff --git a/internal/storage/memory_test.go b/internal/storage/memory_test.go index 846a6ae..cfdc534 100644 --- a/internal/storage/memory_test.go +++ b/internal/storage/memory_test.go @@ -86,4 +86,25 @@ func TestMemory(t *testing.T) { test.OK(t, err) test.Equals(t, task2, act) }) + + t.Run("findbylocalid", func(t *testing.T) { + mem := storage.NewMemory() + test.OK(t, mem.SetTasks(tasks)) + act, err := mem.FindByLocalId(2) + test.OK(t, err) + test.Equals(t, task2, act) + }) + + t.Run("localids", func(t *testing.T) { + mem := storage.NewMemory() + test.OK(t, mem.SetTasks(tasks)) + act, err := mem.LocalIds() + test.OK(t, err) + exp := map[string]int{ + "id-1": 1, + "id-2": 2, + "id-3": 3, + } + test.Equals(t, exp, act) + }) } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index f8c6ae3..2bf3545 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -16,6 +16,7 @@ var sqliteMigrations = []sqliteMigration{ `CREATE TABLE task ("id" TEXT, "version" INTEGER, "folder" TEXT, "action" TEXT, "project" TEXT, "due" TEXT, "recur" TEXT)`, `CREATE TABLE system ("latest_sync" INTEGER)`, `INSERT INTO system (latest_sync) VALUES (0)`, + `CREATE TABLE local_id ("id" TEXT, "local_id" INTEGER)`, } var ( @@ -68,10 +69,12 @@ func (s *Sqlite) LatestSync() (time.Time, error) { } func (s *Sqlite) SetTasks(tasks []*task.Task) error { + // set tasks if _, err := s.db.Exec(`DELETE FROM task`); err != nil { return fmt.Errorf("%w: %v", ErrSqliteFailure, err) } + newIds := []string{} for _, t := range tasks { var recurStr string if t.Recur != nil { @@ -86,8 +89,56 @@ VALUES if err != nil { return fmt.Errorf("%w: %v", ErrSqliteFailure, err) } + + newIds = append(newIds, t.Id) } + // set local_ids + oldIds := map[string]int{} + rows, err := s.db.Query(`SELECT id, local_id FROM local_id`) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + defer rows.Close() + for rows.Next() { + var id string + var local_id int + if err := rows.Scan(&id, &local_id); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + oldIds[id] = local_id + } + + usedLocalIds := []int{} + newLocalIds := map[string]int{} + for _, n := range newIds { + if localId, ok := oldIds[n]; ok { + newLocalIds[n] = localId + usedLocalIds = append(usedLocalIds, localId) + + continue + } + + localId := NextLocalId(usedLocalIds) + newLocalIds[n] = localId + usedLocalIds = append(usedLocalIds, localId) + } + + if _, err := s.db.Exec(`DELETE FROM local_id`); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + for id, localId := range newLocalIds { + _, err := s.db.Exec(` +INSERT INTO local_id +(id, local_id) +VALUES +(?, ?)`, id, localId) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + } + + // update system if _, err := s.db.Exec(` UPDATE system SET latest_sync = ?`, @@ -145,6 +196,41 @@ LIMIT 1`, id) }, nil } +func (s *Sqlite) FindByLocalId(localId int) (*task.Task, error) { + var id string + row := s.db.QueryRow(`SELECT id FROM local_id WHERE local_id = ?`, localId) + if err := row.Scan(&id); err != nil { + return &task.Task{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + t, err := s.FindById(id) + if err != nil { + return &task.Task{}, nil + } + + return t, nil +} + +func (s *Sqlite) LocalIds() (map[string]int, error) { + rows, err := s.db.Query(`SELECT id, local_id FROM local_id`) + if err != nil { + return map[string]int{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + idMap := map[string]int{} + defer rows.Close() + for rows.Next() { + var id string + var local_id int + if err := rows.Scan(&id, &local_id); err != nil { + return map[string]int{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + idMap[id] = local_id + } + + return idMap, nil +} + func tasksFromRows(rows *sql.Rows) ([]*task.Task, error) { tasks := []*task.Task{}