diff --git a/dist/plan b/dist/plan index 51b6d70..7910ed1 100755 Binary files a/dist/plan and b/dist/plan differ diff --git a/item/item.go b/item/item.go index af1b379..e4550ff 100644 --- a/item/item.go +++ b/item/item.go @@ -2,12 +2,17 @@ package item import ( "encoding/json" + "errors" "time" "github.com/google/go-cmp/cmp" "github.com/google/uuid" ) +var ( + ErrInvalidKind = errors.New("invalid kind") +) + type Kind string const ( diff --git a/item/schedule.go b/item/schedule.go new file mode 100644 index 0000000..2f7dfd3 --- /dev/null +++ b/item/schedule.go @@ -0,0 +1,64 @@ +package item + +import ( + "encoding/json" + "fmt" + + "github.com/google/go-cmp/cmp" +) + +type ScheduleBody struct { + Title string `json:"title"` +} + +type Schedule struct { + ID string `json:"id"` + Date Date `json:"date"` + Recurrer Recurrer `json:"recurrer"` + RecurNext Date `json:"recurNext"` + ScheduleBody +} + +func NewSchedule(i Item) (Schedule, error) { + if i.Kind != KindSchedule { + return Schedule{}, ErrInvalidKind + } + + var s Schedule + if err := json.Unmarshal([]byte(i.Body), &s); err != nil { + return Schedule{}, fmt.Errorf("could not unmarshal item body: %v", err) + } + + s.ID = i.ID + s.Date = i.Date + + return s, nil +} + +func (s Schedule) Item() (Item, error) { + body, err := json.Marshal(s.ScheduleBody) + if err != nil { + return Item{}, fmt.Errorf("could not marshal schedule body: %v", err) + } + + return Item{ + ID: s.ID, + Kind: KindSchedule, + Date: s.Date, + Body: string(body), + }, nil +} + +func ScheduleDiff(a, b Schedule) string { + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + + return cmp.Diff(string(aJSON), string(bJSON)) +} + +func ScheduleDiffs(a, b []Schedule) string { + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + + return cmp.Diff(string(aJSON), string(bJSON)) +} diff --git a/item/task.go b/item/task.go index a9eb0d8..4286f57 100644 --- a/item/task.go +++ b/item/task.go @@ -56,7 +56,7 @@ type Task struct { func NewTask(i Item) (Task, error) { if i.Kind != KindTask { - return Task{}, fmt.Errorf("item is not an task") + return Task{}, ErrInvalidKind } var t Task diff --git a/plan/cli/arg/arg.go b/plan/cli/arg/arg.go new file mode 100644 index 0000000..7c2d3cb --- /dev/null +++ b/plan/cli/arg/arg.go @@ -0,0 +1,53 @@ +package arg + +import ( + "fmt" + "slices" + "strings" + + "go-mod.ewintr.nl/planner/plan/command" +) + +func FindFields(args []string) ([]string, map[string]string) { + fields := make(map[string]string) + main := make([]string, 0) + for i := 0; i < len(args); i++ { + // normal key:value + if k, v, ok := strings.Cut(args[i], ":"); ok && !strings.Contains(k, " ") { + fields[k] = v + continue + } + // empty key: + if !strings.Contains(args[i], " ") && strings.HasSuffix(args[i], ":") { + k := strings.TrimSuffix(args[i], ":") + fields[k] = "" + } + main = append(main, args[i]) + } + + return main, fields +} + +func ResolveFields(fields map[string]string, tmpl map[string][]string) (map[string]string, error) { + res := make(map[string]string) + for k, v := range fields { + for tk, tv := range tmpl { + if slices.Contains(tv, k) { + if _, ok := res[tk]; ok { + return nil, fmt.Errorf("%w: duplicate field: %v", command.ErrInvalidArg, tk) + } + res[tk] = v + delete(fields, k) + } + } + } + if len(fields) > 0 { + ks := make([]string, 0, len(fields)) + for k := range fields { + ks = append(ks, k) + } + return nil, fmt.Errorf("%w: unknown field(s): %v", command.ErrInvalidArg, strings.Join(ks, ",")) + } + + return res, nil +} diff --git a/plan/command/command_test.go b/plan/cli/arg/arg_test.go similarity index 93% rename from plan/command/command_test.go rename to plan/cli/arg/arg_test.go index 54dc5a3..0e442ae 100644 --- a/plan/command/command_test.go +++ b/plan/cli/arg/arg_test.go @@ -1,10 +1,10 @@ -package command_test +package arg_test import ( "testing" "github.com/google/go-cmp/cmp" - "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/cli/arg" ) func TestFindFields(t *testing.T) { @@ -55,7 +55,7 @@ func TestFindFields(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - actMain, actFields := command.FindFields(tc.args) + actMain, actFields := arg.FindFields(tc.args) if diff := cmp.Diff(tc.expMain, actMain); diff != "" { t.Errorf("(exp +, got -)\n%s", diff) } @@ -111,7 +111,7 @@ func TestResolveFields(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - actRes, actErr := command.ResolveFields(tc.fields, tmpl) + actRes, actErr := arg.ResolveFields(tc.fields, tmpl) if tc.expErr != (actErr != nil) { t.Errorf("exp %v, got %v", tc.expErr, actErr != nil) } diff --git a/plan/cli/cli.go b/plan/cli/cli.go new file mode 100644 index 0000000..6f811c6 --- /dev/null +++ b/plan/cli/cli.go @@ -0,0 +1,57 @@ +package cli + +import ( + "errors" + "fmt" + + "go-mod.ewintr.nl/planner/plan/cli/arg" + "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/command/schedule" + "go-mod.ewintr.nl/planner/plan/command/task" + "go-mod.ewintr.nl/planner/sync/client" +) + +type CLI struct { + repos command.Repositories + client client.Client + cmdArgs []command.CommandArgs +} + +func NewCLI(repos command.Repositories, client client.Client) *CLI { + return &CLI{ + repos: repos, + client: client, + cmdArgs: []command.CommandArgs{ + command.NewSyncArgs(), + // task + task.NewShowArgs(), task.NewProjectsArgs(), + task.NewAddArgs(), task.NewDeleteArgs(), task.NewListArgs(), + task.NewUpdateArgs(), + // schedule + schedule.NewAddArgs(), + }, + } +} + +func (cli *CLI) Run(args []string) error { + main, fields := arg.FindFields(args) + for _, ca := range cli.cmdArgs { + cmd, err := ca.Parse(main, fields) + switch { + case errors.Is(err, command.ErrWrongCommand): + continue + case err != nil: + return err + } + + result, err := cmd.Do(cli.repos, cli.client) + if err != nil { + return err + } + fmt.Println(result.Render()) + + return nil + } + + return fmt.Errorf("could not find matching command") +} diff --git a/plan/command/command.go b/plan/command/command.go index 4b64508..4384396 100644 --- a/plan/command/command.go +++ b/plan/command/command.go @@ -2,9 +2,6 @@ package command import ( "errors" - "fmt" - "slices" - "strings" "go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/sync/client" @@ -25,6 +22,7 @@ type Repositories interface { LocalID(tx *storage.Tx) storage.LocalID Sync(tx *storage.Tx) storage.Sync Task(tx *storage.Tx) storage.Task + Schedule(tx *storage.Tx) storage.Schedule } type CommandArgs interface { @@ -38,88 +36,3 @@ type Command interface { type CommandResult interface { Render() string } - -type CLI struct { - repos Repositories - client client.Client - cmdArgs []CommandArgs -} - -func NewCLI(repos Repositories, client client.Client) *CLI { - return &CLI{ - repos: repos, - client: client, - cmdArgs: []CommandArgs{ - NewShowArgs(), NewProjectsArgs(), - NewAddArgs(), NewDeleteArgs(), NewListArgs(), - NewSyncArgs(), NewUpdateArgs(), - }, - } -} - -func (cli *CLI) Run(args []string) error { - main, fields := FindFields(args) - for _, ca := range cli.cmdArgs { - cmd, err := ca.Parse(main, fields) - switch { - case errors.Is(err, ErrWrongCommand): - continue - case err != nil: - return err - } - - result, err := cmd.Do(cli.repos, cli.client) - if err != nil { - return err - } - fmt.Println(result.Render()) - - return nil - } - - return fmt.Errorf("could not find matching command") -} - -func FindFields(args []string) ([]string, map[string]string) { - fields := make(map[string]string) - main := make([]string, 0) - for i := 0; i < len(args); i++ { - // normal key:value - if k, v, ok := strings.Cut(args[i], ":"); ok && !strings.Contains(k, " ") { - fields[k] = v - continue - } - // empty key: - if !strings.Contains(args[i], " ") && strings.HasSuffix(args[i], ":") { - k := strings.TrimSuffix(args[i], ":") - fields[k] = "" - } - main = append(main, args[i]) - } - - return main, fields -} - -func ResolveFields(fields map[string]string, tmpl map[string][]string) (map[string]string, error) { - res := make(map[string]string) - for k, v := range fields { - for tk, tv := range tmpl { - if slices.Contains(tv, k) { - if _, ok := res[tk]; ok { - return nil, fmt.Errorf("%w: duplicate field: %v", ErrInvalidArg, tk) - } - res[tk] = v - delete(fields, k) - } - } - } - if len(fields) > 0 { - ks := make([]string, 0, len(fields)) - for k := range fields { - ks = append(ks, k) - } - return nil, fmt.Errorf("%w: unknown field(s): %v", ErrInvalidArg, strings.Join(ks, ",")) - } - - return res, nil -} diff --git a/plan/command/schedule/add.go b/plan/command/schedule/add.go new file mode 100644 index 0000000..c8fe24c --- /dev/null +++ b/plan/command/schedule/add.go @@ -0,0 +1,124 @@ +package schedule + +import ( + "fmt" + "slices" + "strings" + + "github.com/google/uuid" + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/cli/arg" + "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/format" + "go-mod.ewintr.nl/planner/sync/client" +) + +type AddArgs struct { + fieldTPL map[string][]string + Schedule item.Schedule +} + +func NewAddArgs() AddArgs { + return AddArgs{ + fieldTPL: map[string][]string{ + "date": {"d", "date", "on"}, + "recurrer": {"rec", "recurrer"}, + }, + } +} + +func (aa AddArgs) Parse(main []string, fields map[string]string) (command.Command, error) { + if len(main) == 0 || !slices.Contains([]string{"s", "sched", "schedule"}, main[0]) { + return nil, command.ErrWrongCommand + } + main = main[1:] + if len(main) == 0 || !slices.Contains([]string{"add", "a", "new", "n"}, main[0]) { + return nil, command.ErrWrongCommand + } + + main = main[1:] + if len(main) == 0 { + return nil, fmt.Errorf("%w: title is required for add", command.ErrInvalidArg) + } + fields, err := arg.ResolveFields(fields, aa.fieldTPL) + if err != nil { + return nil, err + } + + sched := item.Schedule{ + ID: uuid.New().String(), + ScheduleBody: item.ScheduleBody{ + Title: strings.Join(main, " "), + }, + } + + if val, ok := fields["date"]; ok { + d := item.NewDateFromString(val) + if d.IsZero() { + return nil, fmt.Errorf("%w: could not parse date", command.ErrInvalidArg) + } + sched.Date = d + } + if val, ok := fields["recurrer"]; ok { + rec := item.NewRecurrer(val) + if rec == nil { + return nil, fmt.Errorf("%w: could not parse recurrer", command.ErrInvalidArg) + } + sched.Recurrer = rec + sched.RecurNext = sched.Recurrer.First() + } + + return &Add{ + Args: AddArgs{ + Schedule: sched, + }, + }, nil +} + +type Add struct { + Args AddArgs +} + +func (a Add) Do(repos command.Repositories, _ client.Client) (command.CommandResult, error) { + tx, err := repos.Begin() + if err != nil { + return nil, fmt.Errorf("could not start transaction: %v", err) + } + defer tx.Rollback() + + if err := repos.Schedule(tx).Store(a.Args.Schedule); err != nil { + return nil, fmt.Errorf("could not store schedule: %v", err) + } + + localID, err := repos.LocalID(tx).Next() + if err != nil { + return nil, fmt.Errorf("could not create next local id: %v", err) + } + if err := repos.LocalID(tx).Store(a.Args.Schedule.ID, localID); err != nil { + return nil, fmt.Errorf("could not store local id: %v", err) + } + + it, err := a.Args.Schedule.Item() + if err != nil { + return nil, fmt.Errorf("could not convert schedule to sync item: %v", err) + } + if err := repos.Sync(tx).Store(it); err != nil { + return nil, fmt.Errorf("could not store sync item: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("could not add schedule: %v", err) + } + + return AddResult{ + LocalID: localID, + }, nil +} + +type AddResult struct { + LocalID int +} + +func (ar AddResult) Render() string { + return fmt.Sprintf("stored schedule %s", format.Bold(fmt.Sprintf("%d", ar.LocalID))) +} diff --git a/plan/command/schedule/add_test.go b/plan/command/schedule/add_test.go new file mode 100644 index 0000000..e433c6b --- /dev/null +++ b/plan/command/schedule/add_test.go @@ -0,0 +1,105 @@ +package schedule_test + +import ( + "testing" + + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/command/schedule" + "go-mod.ewintr.nl/planner/plan/storage/memory" +) + +func TestAdd(t *testing.T) { + t.Parallel() + + aDate := item.NewDate(2024, 11, 2) + + for _, tc := range []struct { + name string + main []string + fields map[string]string + expErr bool + expSchedule item.Schedule + }{ + { + name: "empty", + expErr: true, + }, + { + name: "title missing", + main: []string{"sched", "add"}, + fields: map[string]string{ + "date": aDate.String(), + }, + expErr: true, + }, + { + name: "all", + main: []string{"sched", "add", "title"}, + fields: map[string]string{ + "date": aDate.String(), + }, + expSchedule: item.Schedule{ + ID: "title", + Date: aDate, + ScheduleBody: item.ScheduleBody{ + Title: "title", + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // setup + mems := memory.New() + + // parse + cmd, actParseErr := schedule.NewAddArgs().Parse(tc.main, tc.fields) + if tc.expErr != (actParseErr != nil) { + t.Errorf("exp %v, got %v", tc.expErr, actParseErr) + } + if tc.expErr { + return + } + + // do + if _, err := cmd.Do(mems, nil); err != nil { + t.Errorf("exp nil, got %v", err) + } + + // check + actSchedules, err := mems.Schedule(nil).Find(aDate.Add(-1), aDate.Add(1)) + if err != nil { + t.Errorf("exp nil, got %v", err) + } + if len(actSchedules) != 1 { + t.Errorf("exp 1, got %d", len(actSchedules)) + } + + actLocalIDs, err := mems.LocalID(nil).FindAll() + if err != nil { + t.Errorf("exp nil, got %v", err) + } + if len(actLocalIDs) != 1 { + t.Errorf("exp 1, got %v", len(actLocalIDs)) + } + if _, ok := actLocalIDs[actSchedules[0].ID]; !ok { + t.Errorf("exp true, got %v", ok) + } + + if actSchedules[0].ID == "" { + t.Errorf("exp string not te be empty") + } + tc.expSchedule.ID = actSchedules[0].ID + if diff := item.ScheduleDiff(tc.expSchedule, actSchedules[0]); diff != "" { + t.Errorf("(exp -, got +)\n%s", diff) + } + + updated, err := mems.Sync(nil).FindAll() + if err != nil { + t.Errorf("exp nil, got %v", err) + } + if len(updated) != 1 { + t.Errorf("exp 1, got %v", len(updated)) + } + }) + } +} diff --git a/plan/command/add.go b/plan/command/task/add.go similarity index 74% rename from plan/command/add.go rename to plan/command/task/add.go index d5ff520..10f0d6c 100644 --- a/plan/command/add.go +++ b/plan/command/task/add.go @@ -1,4 +1,4 @@ -package command +package task import ( "fmt" @@ -8,6 +8,8 @@ import ( "github.com/google/uuid" "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/cli/arg" + "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/format" "go-mod.ewintr.nl/planner/sync/client" ) @@ -29,16 +31,16 @@ func NewAddArgs() AddArgs { } } -func (aa AddArgs) Parse(main []string, fields map[string]string) (Command, error) { +func (aa AddArgs) Parse(main []string, fields map[string]string) (command.Command, error) { if len(main) == 0 || !slices.Contains([]string{"add", "a", "new", "n"}, main[0]) { - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } main = main[1:] if len(main) == 0 { - return nil, fmt.Errorf("%w: title is required for add", ErrInvalidArg) + return nil, fmt.Errorf("%w: title is required for add", command.ErrInvalidArg) } - fields, err := ResolveFields(fields, aa.fieldTPL) + fields, err := arg.ResolveFields(fields, aa.fieldTPL) if err != nil { return nil, err } @@ -56,28 +58,28 @@ func (aa AddArgs) Parse(main []string, fields map[string]string) (Command, error if val, ok := fields["date"]; ok { d := item.NewDateFromString(val) if d.IsZero() { - return nil, fmt.Errorf("%w: could not parse date", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse date", command.ErrInvalidArg) } tsk.Date = d } if val, ok := fields["time"]; ok { t := item.NewTimeFromString(val) if t.IsZero() { - return nil, fmt.Errorf("%w: could not parse time", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse time", command.ErrInvalidArg) } tsk.Time = t } if val, ok := fields["duration"]; ok { d, err := time.ParseDuration(val) if err != nil { - return nil, fmt.Errorf("%w: could not parse duration", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse duration", command.ErrInvalidArg) } tsk.Duration = d } if val, ok := fields["recurrer"]; ok { rec := item.NewRecurrer(val) if rec == nil { - return nil, fmt.Errorf("%w: could not parse recurrer", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse recurrer", command.ErrInvalidArg) } tsk.Recurrer = rec tsk.RecurNext = tsk.Recurrer.First() @@ -94,7 +96,7 @@ type Add struct { Args AddArgs } -func (a Add) Do(repos Repositories, _ client.Client) (CommandResult, error) { +func (a Add) Do(repos command.Repositories, _ client.Client) (command.CommandResult, error) { tx, err := repos.Begin() if err != nil { return nil, fmt.Errorf("could not start transaction: %v", err) @@ -102,7 +104,7 @@ func (a Add) Do(repos Repositories, _ client.Client) (CommandResult, error) { defer tx.Rollback() if err := repos.Task(tx).Store(a.Args.Task); err != nil { - return nil, fmt.Errorf("could not store event: %v", err) + return nil, fmt.Errorf("could not store task: %v", err) } localID, err := repos.LocalID(tx).Next() @@ -115,7 +117,7 @@ func (a Add) Do(repos Repositories, _ client.Client) (CommandResult, error) { it, err := a.Args.Task.Item() if err != nil { - return nil, fmt.Errorf("could not convert event to sync item: %v", err) + return nil, fmt.Errorf("could not convert task to sync item: %v", err) } if err := repos.Sync(tx).Store(it); err != nil { return nil, fmt.Errorf("could not store sync item: %v", err) diff --git a/plan/command/add_test.go b/plan/command/task/add_test.go similarity index 94% rename from plan/command/add_test.go rename to plan/command/task/add_test.go index e8d29c2..20985af 100644 --- a/plan/command/add_test.go +++ b/plan/command/task/add_test.go @@ -1,11 +1,11 @@ -package command_test +package task_test import ( "testing" "time" "go-mod.ewintr.nl/planner/item" - "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/command/task" "go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/plan/storage/memory" ) @@ -63,7 +63,7 @@ func TestAdd(t *testing.T) { mems := memory.New() // parse - cmd, actParseErr := command.NewAddArgs().Parse(tc.main, tc.fields) + cmd, actParseErr := task.NewAddArgs().Parse(tc.main, tc.fields) if tc.expErr != (actParseErr != nil) { t.Errorf("exp %v, got %v", tc.expErr, actParseErr) } diff --git a/plan/command/delete.go b/plan/command/task/delete.go similarity index 89% rename from plan/command/delete.go rename to plan/command/task/delete.go index e7b2e5e..cb006f9 100644 --- a/plan/command/delete.go +++ b/plan/command/task/delete.go @@ -1,10 +1,11 @@ -package command +package task import ( "fmt" "slices" "strconv" + "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/format" "go-mod.ewintr.nl/planner/sync/client" ) @@ -17,9 +18,9 @@ func NewDeleteArgs() DeleteArgs { return DeleteArgs{} } -func (da DeleteArgs) Parse(main []string, flags map[string]string) (Command, error) { +func (da DeleteArgs) Parse(main []string, flags map[string]string) (command.Command, error) { if len(main) != 2 { - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } aliases := []string{"d", "delete", "done"} var localIDStr string @@ -29,7 +30,7 @@ func (da DeleteArgs) Parse(main []string, flags map[string]string) (Command, err case slices.Contains(aliases, main[1]): localIDStr = main[0] default: - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } localID, err := strconv.Atoi(localIDStr) @@ -48,7 +49,7 @@ type Delete struct { Args DeleteArgs } -func (del Delete) Do(repos Repositories, _ client.Client) (CommandResult, error) { +func (del Delete) Do(repos command.Repositories, _ client.Client) (command.CommandResult, error) { tx, err := repos.Begin() if err != nil { return nil, fmt.Errorf("could not start transaction: %v", err) diff --git a/plan/command/delete_test.go b/plan/command/task/delete_test.go similarity index 93% rename from plan/command/delete_test.go rename to plan/command/task/delete_test.go index d6cfc90..e863906 100644 --- a/plan/command/delete_test.go +++ b/plan/command/task/delete_test.go @@ -1,11 +1,11 @@ -package command_test +package task_test import ( "errors" "testing" "go-mod.ewintr.nl/planner/item" - "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/command/task" "go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/plan/storage/memory" ) @@ -59,7 +59,7 @@ func TestDelete(t *testing.T) { } // parse - cmd, actParseErr := command.NewDeleteArgs().Parse(tc.main, tc.flags) + cmd, actParseErr := task.NewDeleteArgs().Parse(tc.main, tc.flags) if tc.expParseErr != (actParseErr != nil) { t.Errorf("exp %v, got %v", tc.expParseErr, actParseErr) } diff --git a/plan/command/list.go b/plan/command/task/list.go similarity index 92% rename from plan/command/list.go rename to plan/command/task/list.go index 38834d3..b084033 100644 --- a/plan/command/list.go +++ b/plan/command/task/list.go @@ -1,4 +1,4 @@ -package command +package task import ( "fmt" @@ -7,6 +7,8 @@ import ( "time" "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/cli/arg" + "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/format" "go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/sync/client" @@ -31,12 +33,12 @@ func NewListArgs() ListArgs { } } -func (la ListArgs) Parse(main []string, fields map[string]string) (Command, error) { +func (la ListArgs) Parse(main []string, fields map[string]string) (command.Command, error) { if len(main) > 1 { - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } - fields, err := ResolveFields(fields, la.fieldTPL) + fields, err := arg.ResolveFields(fields, la.fieldTPL) if err != nil { return nil, err } @@ -63,7 +65,7 @@ func (la ListArgs) Parse(main []string, fields map[string]string) (Command, erro // fields["from"] = today.String() // fields["to"] = today.String() default: - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } } @@ -97,7 +99,7 @@ type List struct { Args ListArgs } -func (list List) Do(repos Repositories, _ client.Client) (CommandResult, error) { +func (list List) Do(repos command.Repositories, _ client.Client) (command.CommandResult, error) { tx, err := repos.Begin() if err != nil { return nil, fmt.Errorf("could not start transaction: %v", err) diff --git a/plan/command/list_test.go b/plan/command/task/list_test.go similarity index 84% rename from plan/command/list_test.go rename to plan/command/task/list_test.go index 133998e..1923d0a 100644 --- a/plan/command/list_test.go +++ b/plan/command/task/list_test.go @@ -1,4 +1,4 @@ -package command_test +package task_test import ( "testing" @@ -7,7 +7,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "go-mod.ewintr.nl/planner/item" - "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/command/task" "go-mod.ewintr.nl/planner/plan/storage/memory" ) @@ -20,20 +20,20 @@ func TestListParse(t *testing.T) { name string main []string fields map[string]string - expArgs command.ListArgs + expArgs task.ListArgs expErr bool }{ { name: "empty", main: []string{}, fields: map[string]string{}, - expArgs: command.ListArgs{}, + expArgs: task.ListArgs{}, }, { name: "today", main: []string{"tod"}, fields: map[string]string{}, - expArgs: command.ListArgs{ + expArgs: task.ListArgs{ To: today, }, }, @@ -41,7 +41,7 @@ func TestListParse(t *testing.T) { name: "tomorrow", main: []string{"tom"}, fields: map[string]string{}, - expArgs: command.ListArgs{ + expArgs: task.ListArgs{ From: today.Add(1), To: today.Add(1), }, @@ -50,14 +50,14 @@ func TestListParse(t *testing.T) { name: "week", main: []string{"week"}, fields: map[string]string{}, - expArgs: command.ListArgs{ + expArgs: task.ListArgs{ From: today, To: today.Add(7), }, }, } { t.Run(tc.name, func(t *testing.T) { - nla := command.NewListArgs() + nla := task.NewListArgs() cmd, actErr := nla.Parse(tc.main, tc.fields) if tc.expErr != (actErr != nil) { t.Errorf("exp %v, got %v", tc.expErr, actErr != nil) @@ -65,7 +65,7 @@ func TestListParse(t *testing.T) { if tc.expErr { return } - listCmd, ok := cmd.(command.List) + listCmd, ok := cmd.(task.List) if !ok { t.Errorf("exp true, got false") } @@ -97,7 +97,7 @@ func TestList(t *testing.T) { for _, tc := range []struct { name string - cmd command.List + cmd task.List expRes bool expErr bool }{ @@ -107,8 +107,8 @@ func TestList(t *testing.T) { }, { name: "empty list", - cmd: command.List{ - Args: command.ListArgs{ + cmd: task.List{ + Args: task.ListArgs{ HasRecurrer: true, }, }, @@ -120,7 +120,7 @@ func TestList(t *testing.T) { t.Errorf("exp nil, got %v", err) } - listRes := res.(command.ListResult) + listRes := res.(task.ListResult) actRes := len(listRes.Tasks) > 0 if tc.expRes != actRes { t.Errorf("exp %v, got %v", tc.expRes, actRes) diff --git a/plan/command/projects.go b/plan/command/task/projects.go similarity index 84% rename from plan/command/projects.go rename to plan/command/task/projects.go index e001b34..88f743a 100644 --- a/plan/command/projects.go +++ b/plan/command/task/projects.go @@ -1,9 +1,10 @@ -package command +package task import ( "fmt" "sort" + "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/format" "go-mod.ewintr.nl/planner/sync/client" ) @@ -14,9 +15,9 @@ func NewProjectsArgs() ProjectsArgs { return ProjectsArgs{} } -func (pa ProjectsArgs) Parse(main []string, fields map[string]string) (Command, error) { +func (pa ProjectsArgs) Parse(main []string, fields map[string]string) (command.Command, error) { if len(main) != 1 || main[0] != "projects" { - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } return Projects{}, nil @@ -24,7 +25,7 @@ func (pa ProjectsArgs) Parse(main []string, fields map[string]string) (Command, type Projects struct{} -func (ps Projects) Do(repos Repositories, _ client.Client) (CommandResult, error) { +func (ps Projects) Do(repos command.Repositories, _ client.Client) (command.CommandResult, error) { tx, err := repos.Begin() if err != nil { return nil, fmt.Errorf("could not start transaction: %v", err) diff --git a/plan/command/show.go b/plan/command/task/show.go similarity index 86% rename from plan/command/show.go rename to plan/command/task/show.go index c3995fe..c5c0589 100644 --- a/plan/command/show.go +++ b/plan/command/task/show.go @@ -1,4 +1,4 @@ -package command +package task import ( "errors" @@ -6,6 +6,7 @@ import ( "strconv" "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/format" "go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/sync/client" @@ -19,13 +20,13 @@ func NewShowArgs() ShowArgs { return ShowArgs{} } -func (sa ShowArgs) Parse(main []string, fields map[string]string) (Command, error) { +func (sa ShowArgs) Parse(main []string, fields map[string]string) (command.Command, error) { if len(main) != 1 { - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } lid, err := strconv.Atoi(main[0]) if err != nil { - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } return &Show{ @@ -39,7 +40,7 @@ type Show struct { args ShowArgs } -func (s Show) Do(repos Repositories, _ client.Client) (CommandResult, error) { +func (s Show) Do(repos command.Repositories, _ client.Client) (command.CommandResult, error) { tx, err := repos.Begin() if err != nil { return nil, fmt.Errorf("could not start transaction: %v", err) diff --git a/plan/command/show_test.go b/plan/command/task/show_test.go similarity index 92% rename from plan/command/show_test.go rename to plan/command/task/show_test.go index 26dd84a..88047d2 100644 --- a/plan/command/show_test.go +++ b/plan/command/task/show_test.go @@ -1,11 +1,11 @@ -package command_test +package task_test import ( "fmt" "testing" "go-mod.ewintr.nl/planner/item" - "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/command/task" "go-mod.ewintr.nl/planner/plan/storage/memory" ) @@ -60,7 +60,7 @@ func TestShow(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { // parse - cmd, actParseErr := command.NewShowArgs().Parse(tc.main, nil) + cmd, actParseErr := task.NewShowArgs().Parse(tc.main, nil) if tc.expParseErr != (actParseErr != nil) { t.Errorf("exp %v, got %v", tc.expParseErr, actParseErr != nil) } diff --git a/plan/command/update.go b/plan/command/task/update.go similarity index 87% rename from plan/command/update.go rename to plan/command/task/update.go index 1c5664f..70557a0 100644 --- a/plan/command/update.go +++ b/plan/command/task/update.go @@ -1,4 +1,4 @@ -package command +package task import ( "errors" @@ -9,6 +9,8 @@ import ( "time" "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/cli/arg" + "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/format" "go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/sync/client" @@ -38,9 +40,9 @@ func NewUpdateArgs() UpdateArgs { } } -func (ua UpdateArgs) Parse(main []string, fields map[string]string) (Command, error) { +func (ua UpdateArgs) Parse(main []string, fields map[string]string) (command.Command, error) { if len(main) < 2 { - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } aliases := []string{"u", "update", "m", "mod"} var localIDStr string @@ -50,13 +52,13 @@ func (ua UpdateArgs) Parse(main []string, fields map[string]string) (Command, er case slices.Contains(aliases, main[1]): localIDStr = main[0] default: - return nil, ErrWrongCommand + return nil, command.ErrWrongCommand } localID, err := strconv.Atoi(localIDStr) if err != nil { return nil, fmt.Errorf("not a local id: %v", main[1]) } - fields, err = ResolveFields(fields, ua.fieldTPL) + fields, err = arg.ResolveFields(fields, ua.fieldTPL) if err != nil { return nil, err } @@ -75,7 +77,7 @@ func (ua UpdateArgs) Parse(main []string, fields map[string]string) (Command, er if val != "" { d := item.NewDateFromString(val) if d.IsZero() { - return nil, fmt.Errorf("%w: could not parse date", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse date", command.ErrInvalidArg) } args.Date = d } @@ -85,7 +87,7 @@ func (ua UpdateArgs) Parse(main []string, fields map[string]string) (Command, er if val != "" { t := item.NewTimeFromString(val) if t.IsZero() { - return nil, fmt.Errorf("%w: could not parse time", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse time", command.ErrInvalidArg) } args.Time = t } @@ -95,7 +97,7 @@ func (ua UpdateArgs) Parse(main []string, fields map[string]string) (Command, er if val != "" { d, err := time.ParseDuration(val) if err != nil { - return nil, fmt.Errorf("%w: could not parse duration", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse duration", command.ErrInvalidArg) } args.Duration = d } @@ -105,7 +107,7 @@ func (ua UpdateArgs) Parse(main []string, fields map[string]string) (Command, er if val != "" { rec := item.NewRecurrer(val) if rec == nil { - return nil, fmt.Errorf("%w: could not parse recurrer", ErrInvalidArg) + return nil, fmt.Errorf("%w: could not parse recurrer", command.ErrInvalidArg) } args.Recurrer = rec } @@ -118,7 +120,7 @@ type Update struct { args UpdateArgs } -func (u Update) Do(repos Repositories, _ client.Client) (CommandResult, error) { +func (u Update) Do(repos command.Repositories, _ client.Client) (command.CommandResult, error) { tx, err := repos.Begin() if err != nil { return nil, fmt.Errorf("could not start transaction: %v", err) diff --git a/plan/command/update_test.go b/plan/command/task/update_test.go similarity index 97% rename from plan/command/update_test.go rename to plan/command/task/update_test.go index 92f0728..7eb4928 100644 --- a/plan/command/update_test.go +++ b/plan/command/task/update_test.go @@ -1,4 +1,4 @@ -package command_test +package task_test import ( "fmt" @@ -6,7 +6,7 @@ import ( "time" "go-mod.ewintr.nl/planner/item" - "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/command/task" "go-mod.ewintr.nl/planner/plan/storage/memory" ) @@ -207,7 +207,7 @@ func TestUpdateExecute(t *testing.T) { } // parse - cmd, actErr := command.NewUpdateArgs().Parse(tc.main, tc.fields) + cmd, actErr := task.NewUpdateArgs().Parse(tc.main, tc.fields) if tc.expParseErr != (actErr != nil) { t.Errorf("exp %v, got %v", tc.expParseErr, actErr) } diff --git a/plan/main.go b/plan/main.go index e4b0dbe..0e373d8 100644 --- a/plan/main.go +++ b/plan/main.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/cli" "go-mod.ewintr.nl/planner/plan/storage/sqlite" "go-mod.ewintr.nl/planner/sync/client" "gopkg.in/yaml.v3" @@ -35,7 +35,7 @@ func main() { syncClient := client.New(conf.SyncURL, conf.ApiKey) - cli := command.NewCLI(repos, syncClient) + cli := cli.NewCLI(repos, syncClient) if err := cli.Run(os.Args[1:]); err != nil { fmt.Println(err) os.Exit(1) diff --git a/plan/storage/memory/memory.go b/plan/storage/memory/memory.go index 4384780..56c936e 100644 --- a/plan/storage/memory/memory.go +++ b/plan/storage/memory/memory.go @@ -5,16 +5,18 @@ import ( ) type Memories struct { - localID *LocalID - sync *Sync - task *Task + localID *LocalID + sync *Sync + task *Task + schedule *Schedule } func New() *Memories { return &Memories{ - localID: NewLocalID(), - sync: NewSync(), - task: NewTask(), + localID: NewLocalID(), + sync: NewSync(), + task: NewTask(), + schedule: NewSchedule(), } } @@ -33,3 +35,7 @@ func (mems *Memories) Sync(_ *storage.Tx) storage.Sync { func (mems *Memories) Task(_ *storage.Tx) storage.Task { return mems.task } + +func (mems *Memories) Schedule(_ *storage.Tx) storage.Schedule { + return mems.schedule +} diff --git a/plan/storage/memory/schedule.go b/plan/storage/memory/schedule.go new file mode 100644 index 0000000..84639b8 --- /dev/null +++ b/plan/storage/memory/schedule.go @@ -0,0 +1,54 @@ +package memory + +import ( + "sync" + + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/storage" +) + +type Schedule struct { + scheds map[string]item.Schedule + mutex sync.RWMutex +} + +func NewSchedule() *Schedule { + return &Schedule{ + scheds: make(map[string]item.Schedule), + } +} + +func (s *Schedule) Store(sched item.Schedule) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.scheds[sched.ID] = sched + return nil +} + +func (s *Schedule) Find(start, end item.Date) ([]item.Schedule, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + res := make([]item.Schedule, 0) + for _, sched := range s.scheds { + if start.After(sched.Date) || sched.Date.After(end) { + continue + } + res = append(res, sched) + } + + return res, nil +} + +func (s *Schedule) Delete(id string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + if _, exists := s.scheds[id]; !exists { + return storage.ErrNotFound + } + delete(s.scheds, id) + + return nil +} diff --git a/plan/storage/memory/schedule_test.go b/plan/storage/memory/schedule_test.go new file mode 100644 index 0000000..458e3fc --- /dev/null +++ b/plan/storage/memory/schedule_test.go @@ -0,0 +1,87 @@ +package memory_test + +import ( + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/storage/memory" +) + +func TestSchedule(t *testing.T) { + t.Parallel() + + mem := memory.NewSchedule() + + actScheds, actErr := mem.Find(item.NewDateFromString("1900-01-01"), item.NewDateFromString("9999-12-31")) + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if len(actScheds) != 0 { + t.Errorf("exp 0, got %d", len(actScheds)) + } + + s1 := item.Schedule{ + ID: "id-1", + Date: item.NewDateFromString("2025-01-20"), + } + if err := mem.Store(s1); err != nil { + t.Errorf("exp nil, got %v", err) + } + s2 := item.Schedule{ + ID: "id-2", + Date: item.NewDateFromString("2025-01-21"), + } + if err := mem.Store(s2); err != nil { + t.Errorf("exp nil, got %v", err) + } + + for _, tc := range []struct { + name string + start string + end string + exp []string + }{ + { + name: "all", + start: "1900-01-01", + end: "9999-12-31", + exp: []string{s1.ID, s2.ID}, + }, + { + name: "last", + start: s2.Date.String(), + end: "9999-12-31", + exp: []string{s2.ID}, + }, + { + name: "first", + start: "1900-01-01", + end: s1.Date.String(), + exp: []string{s1.ID}, + }, + { + name: "none", + start: "1900-01-01", + end: "2025-01-01", + exp: make([]string, 0), + }, + } { + t.Run(tc.name, func(t *testing.T) { + actScheds, actErr = mem.Find(item.NewDateFromString(tc.start), item.NewDateFromString(tc.end)) + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + actIDs := make([]string, 0, len(actScheds)) + for _, s := range actScheds { + actIDs = append(actIDs, s.ID) + } + sort.Strings(actIDs) + if diff := cmp.Diff(tc.exp, actIDs); diff != "" { + t.Errorf("(+exp, -got)%s\n", diff) + } + }) + } + +} diff --git a/plan/storage/memory/task.go b/plan/storage/memory/task.go index c91ef8d..b5ba26b 100644 --- a/plan/storage/memory/task.go +++ b/plan/storage/memory/task.go @@ -36,7 +36,7 @@ func (t *Task) FindMany(params storage.TaskListParams) ([]item.Task, error) { tasks := make([]item.Task, 0, len(t.tasks)) for _, tsk := range t.tasks { - if storage.Match(tsk, params) { + if storage.MatchTask(tsk, params) { tasks = append(tasks, tsk) } } diff --git a/plan/storage/sqlite/migrations.go b/plan/storage/sqlite/migrations.go index a786d46..af70c7e 100644 --- a/plan/storage/sqlite/migrations.go +++ b/plan/storage/sqlite/migrations.go @@ -44,4 +44,11 @@ var migrations = []string{ `ALTER TABLE tasks ADD COLUMN project TEXT NOT NULL DEFAULT ''`, `CREATE TABLE syncupdate ("timestamp" TIMESTAMP NOT NULL)`, `INSERT INTO syncupdate (timestamp) VALUES ("0001-01-01T00:00:00Z")`, + + `CREATE TABLE schedules ( + "id" TEXT UNIQUE NOT NULL DEFAULT '', + "title" TEXT NOT NULL DEFAULT '', + "date" TEXT NOT NULL DEFAULT '', + "recur" TEXT NOT NULL DEFAULT '', + "recur_next" TEXT NOT NULL DEFAULT '')`, } diff --git a/plan/storage/sqlite/schedule.go b/plan/storage/sqlite/schedule.go new file mode 100644 index 0000000..9abf56a --- /dev/null +++ b/plan/storage/sqlite/schedule.go @@ -0,0 +1,81 @@ +package sqlite + +import ( + "fmt" + + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/storage" +) + +type SqliteSchedule struct { + tx *storage.Tx +} + +func (ss *SqliteSchedule) Store(sched item.Schedule) error { + var recurStr string + if sched.Recurrer != nil { + recurStr = sched.Recurrer.String() + } + if _, err := ss.tx.Exec(` +INSERT INTO schedules +(id, title, date, recur) +VALUES +(?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE +SET +title=?, +date=?, +recur=? +`, + sched.ID, sched.Title, sched.Date.String(), recurStr, + sched.Title, sched.Date.String(), recurStr); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + return nil +} + +func (ss *SqliteSchedule) Find(start, end item.Date) ([]item.Schedule, error) { + rows, err := ss.tx.Query(`SELECT +id, title, date, recur +FROM schedules +WHERE date >= ? AND date <= ?`, start.String(), end.String()) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + defer rows.Close() + scheds := make([]item.Schedule, 0) + for rows.Next() { + var sched item.Schedule + var dateStr, recurStr string + if err := rows.Scan(&sched.ID, &sched.Title, &dateStr, &recurStr); err != nil { + return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + sched.Date = item.NewDateFromString(dateStr) + sched.Recurrer = item.NewRecurrer(recurStr) + + scheds = append(scheds, sched) + } + + return scheds, nil +} + +func (ss *SqliteSchedule) Delete(id string) error { + + result, err := ss.tx.Exec(` +DELETE FROM schedules +WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + if rowsAffected == 0 { + return storage.ErrNotFound + } + + return nil +} diff --git a/plan/storage/sqlite/sqlite.go b/plan/storage/sqlite/sqlite.go index 7f884f9..0c2acea 100644 --- a/plan/storage/sqlite/sqlite.go +++ b/plan/storage/sqlite/sqlite.go @@ -44,6 +44,10 @@ func (sqs *Sqlites) Task(tx *storage.Tx) storage.Task { return &SqliteTask{tx: tx} } +func (sqs *Sqlites) Schedule(tx *storage.Tx) storage.Schedule { + return &SqliteSchedule{tx: tx} +} + func NewSqlites(dbPath string) (*Sqlites, error) { db, err := sql.Open("sqlite", dbPath) if err != nil { diff --git a/plan/storage/storage.go b/plan/storage/storage.go index 6bbc449..3230183 100644 --- a/plan/storage/storage.go +++ b/plan/storage/storage.go @@ -49,7 +49,13 @@ type Task interface { Projects() (map[string]int, error) } -func Match(tsk item.Task, params TaskListParams) bool { +type Schedule interface { + Store(sched item.Schedule) error + Find(start, end item.Date) ([]item.Schedule, error) + Delete(id string) error +} + +func MatchTask(tsk item.Task, params TaskListParams) bool { if params.HasRecurrer && tsk.Recurrer == nil { return false } diff --git a/plan/storage/storage_test.go b/plan/storage/storage_test.go index 7ed78ae..5bc62d8 100644 --- a/plan/storage/storage_test.go +++ b/plan/storage/storage_test.go @@ -59,10 +59,10 @@ func TestMatch(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - if !storage.Match(tskMatch, tc.params) { + if !storage.MatchTask(tskMatch, tc.params) { t.Errorf("exp tsk to match") } - if storage.Match(tskNotMatch, tc.params) { + if storage.MatchTask(tskNotMatch, tc.params) { t.Errorf("exp tsk to not match") } })