own argument parser and flags

This commit is contained in:
Erik Winter 2024-10-29 07:22:04 +01:00
parent 6fe8561a6b
commit 5b5ae5727d
21 changed files with 1117 additions and 315 deletions

View File

@ -86,3 +86,17 @@ func (e Event) Item() (Item, error) {
Body: string(body),
}, nil
}
func (e Event) Valid() bool {
if e.Title == "" {
return false
}
if e.Start.IsZero() || e.Start.Year() < 2024 {
return false
}
if e.Duration.Seconds() < 1 {
return false
}
return true
}

View File

@ -132,3 +132,72 @@ func TestEventItem(t *testing.T) {
})
}
}
func TestEventValidate(t *testing.T) {
t.Parallel()
oneHour, err := time.ParseDuration("1h")
if err != nil {
t.Errorf("exp nil, got %v", err)
}
for _, tc := range []struct {
name string
event item.Event
exp bool
}{
{
name: "empty",
},
{
name: "missing title",
event: item.Event{
ID: "a",
EventBody: item.EventBody{
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),
Duration: oneHour,
},
},
},
{
name: "no date",
event: item.Event{
ID: "a",
EventBody: item.EventBody{
Title: "title",
Start: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC),
Duration: oneHour,
},
},
},
{
name: "no duration",
event: item.Event{
ID: "a",
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),
},
},
},
{
name: "valid",
event: item.Event{
ID: "a",
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),
Duration: oneHour,
},
},
exp: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if act := tc.event.Valid(); tc.exp != act {
t.Errorf("exp %v, got %v", tc.exp, act)
}
})
}
}

View File

@ -1,105 +1,107 @@
package command
import (
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/urfave/cli/v2"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/storage"
)
var (
ErrInvalidArg = errors.New("invalid argument")
)
var AddCmd = &cli.Command{
Name: "add",
Usage: "Add a new event",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "The event that will happen",
Required: true,
},
&cli.StringFlag{
Name: "on",
Aliases: []string{"o"},
Usage: "The date, in YYYY-MM-DD format",
Required: true,
},
&cli.StringFlag{
Name: "at",
Aliases: []string{"a"},
Usage: "The time, in HH:MM format. If omitted, the event will last the whole day",
},
&cli.StringFlag{
Name: "for",
Aliases: []string{"f"},
Usage: "The duration, in show format (e.g. 1h30m)",
},
},
type Add struct {
localIDRepo storage.LocalID
eventRepo storage.Event
syncRepo storage.Sync
argSet *ArgSet
}
func NewAddCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command {
AddCmd.Action = func(cCtx *cli.Context) error {
return Add(localRepo, eventRepo, syncRepo, cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for"))
func NewAdd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) Command {
return &Add{
localIDRepo: localRepo,
eventRepo: eventRepo,
syncRepo: syncRepo,
argSet: &ArgSet{
Flags: map[string]Flag{
FlagOn: &FlagDate{},
FlagAt: &FlagTime{},
FlagFor: &FlagDuration{},
},
},
}
return AddCmd
}
func Add(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, nameStr, onStr, atStr, frStr string) error {
if nameStr == "" {
return fmt.Errorf("%w: name is required", ErrInvalidArg)
func (add *Add) Execute(main []string, flags map[string]string) error {
if len(main) == 0 || main[0] != "add" {
return ErrWrongCommand
}
if onStr == "" {
as := add.argSet
if len(main) > 1 {
as.Main = strings.Join(main[1:], " ")
}
for k := range as.Flags {
v, ok := flags[k]
if !ok {
continue
}
if err := as.Set(k, v); err != nil {
return fmt.Errorf("could not set %s: %v", k, err)
}
}
if as.Main == "" {
return fmt.Errorf("%w: title is required", ErrInvalidArg)
}
if !as.IsSet(FlagOn) {
return fmt.Errorf("%w: date is required", ErrInvalidArg)
}
if atStr == "" && frStr != "" {
if !as.IsSet(FlagAt) && as.IsSet(FlagFor) {
return fmt.Errorf("%w: can not have duration without start time", ErrInvalidArg)
}
if atStr == "" && frStr == "" {
frStr = "24h"
if as.IsSet(FlagAt) && !as.IsSet(FlagFor) {
if err := as.Flags[FlagFor].Set("1h"); err != nil {
return fmt.Errorf("could not set duration to one hour")
}
}
if !as.IsSet(FlagAt) && !as.IsSet(FlagFor) {
if err := as.Flags[FlagFor].Set("24h"); err != nil {
return fmt.Errorf("could not set duration to 24 hours")
}
}
startFormat := "2006-01-02"
startStr := onStr
if atStr != "" {
startFormat = fmt.Sprintf("%s 15:04", startFormat)
startStr = fmt.Sprintf("%s %s", startStr, atStr)
}
start, err := time.Parse(startFormat, startStr)
if err != nil {
return fmt.Errorf("%w: could not parse start time and date: %v", ErrInvalidArg, err)
return add.do()
}
func (add *Add) do() error {
as := add.argSet
start := as.GetTime(FlagOn)
if as.IsSet(FlagAt) {
at := as.GetTime(FlagAt)
h := time.Duration(at.Hour()) * time.Hour
m := time.Duration(at.Minute()) * time.Minute
start = start.Add(h).Add(m)
}
e := item.Event{
ID: uuid.New().String(),
EventBody: item.EventBody{
Title: nameStr,
Title: as.Main,
Start: start,
},
}
if frStr != "" {
fr, err := time.ParseDuration(frStr)
if err != nil {
return fmt.Errorf("%w: could not parse duration: %s", ErrInvalidArg, err)
if as.IsSet(FlagFor) {
e.Duration = as.GetDuration(FlagFor)
}
e.Duration = fr
}
if err := eventRepo.Store(e); err != nil {
if err := add.eventRepo.Store(e); err != nil {
return fmt.Errorf("could not store event: %v", err)
}
localID, err := localIDRepo.Next()
localID, err := add.localIDRepo.Next()
if err != nil {
return fmt.Errorf("could not create next local id: %v", err)
}
if err := localIDRepo.Store(e.ID, localID); err != nil {
if err := add.localIDRepo.Store(e.ID, localID); err != nil {
return fmt.Errorf("could not store local id: %v", err)
}
@ -107,7 +109,7 @@ func Add(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.
if err != nil {
return fmt.Errorf("could not convert event to sync item: %v", err)
}
if err := syncRepo.Store(it); err != nil {
if err := add.syncRepo.Store(it); err != nil {
return fmt.Errorf("could not store sync item: %v", err)
}

View File

@ -13,107 +13,109 @@ import (
func TestAdd(t *testing.T) {
t.Parallel()
oneHour, err := time.ParseDuration("1h")
if err != nil {
t.Errorf("exp nil, got %v", err)
}
oneDay, err := time.ParseDuration("24h")
if err != nil {
t.Errorf("exp nil, got %v", err)
}
aDateStr := "2024-11-02"
aDate := time.Date(2024, 11, 2, 0, 0, 0, 0, time.UTC)
aTimeStr := "12:00"
aDay := time.Duration(24) * time.Hour
anHourStr := "1h"
anHour := time.Hour
aDateAndTime := time.Date(2024, 11, 2, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
args map[string]string
expEvent item.Event
main []string
flags map[string]string
expErr bool
expEvent item.Event
}{
{
name: "no name",
args: map[string]string{
"on": "2024-10-01",
"at": "9:00",
"for": "1h",
name: "empty",
expErr: true,
},
{
name: "title missing",
main: []string{"add"},
flags: map[string]string{
command.FlagOn: aDateStr,
},
expErr: true,
},
{
name: "no date",
args: map[string]string{
"name": "event",
"at": "9:00",
"for": "1h",
},
name: "date missing",
main: []string{"add", "some", "title"},
expErr: true,
},
{
name: "duration, but no time",
args: map[string]string{
"name": "event",
"on": "2024-10-01",
"for": "1h",
name: "only date",
main: []string{"add", "title"},
flags: map[string]string{
command.FlagOn: aDateStr,
},
expEvent: item.Event{
ID: "title",
EventBody: item.EventBody{
Title: "title",
Start: aDate,
Duration: aDay,
},
},
},
{
name: "date and time",
main: []string{"add", "title"},
flags: map[string]string{
command.FlagOn: aDateStr,
command.FlagAt: aTimeStr,
},
expEvent: item.Event{
ID: "title",
EventBody: item.EventBody{
Title: "title",
Start: aDateAndTime,
Duration: anHour,
},
},
},
{
name: "date, time and duration",
main: []string{"add", "title"},
flags: map[string]string{
command.FlagOn: aDateStr,
command.FlagAt: aTimeStr,
command.FlagFor: anHourStr,
},
expEvent: item.Event{
ID: "title",
EventBody: item.EventBody{
Title: "title",
Start: aDateAndTime,
Duration: anHour,
},
},
},
{
name: "date and duration",
main: []string{"add", "title"},
flags: map[string]string{
command.FlagOn: aDateStr,
command.FlagFor: anHourStr,
},
expErr: true,
},
{
name: "time, but no duration",
args: map[string]string{
"name": "event",
"on": "2024-10-01",
"at": "9:00",
},
expEvent: item.Event{
ID: "a",
EventBody: item.EventBody{
Title: "event",
Start: time.Date(2024, 10, 1, 9, 0, 0, 0, time.UTC),
},
},
},
{
name: "no time, no duration",
args: map[string]string{
"name": "event",
"on": "2024-10-01",
},
expEvent: item.Event{
ID: "a",
EventBody: item.EventBody{
Title: "event",
Start: time.Date(2024, 10, 1, 0, 0, 0, 0, time.UTC),
Duration: oneDay,
},
},
},
{
name: "full",
args: map[string]string{
"name": "event",
"on": "2024-10-01",
"at": "9:00",
"for": "1h",
},
expEvent: item.Event{
ID: "a",
EventBody: item.EventBody{
Title: "event",
Start: time.Date(2024, 10, 1, 9, 0, 0, 0, time.UTC),
Duration: oneHour,
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
eventRepo := memory.NewEvent()
localRepo := memory.NewLocalID()
syncRepo := memory.NewSync()
actErr := command.Add(localRepo, eventRepo, syncRepo, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil
if tc.expErr != actErr {
t.Errorf("exp %v, got %v", tc.expErr, actErr)
cmd := command.NewAdd(localRepo, eventRepo, syncRepo)
actParseErr := cmd.Execute(tc.main, tc.flags) != nil
if tc.expErr != actParseErr {
t.Errorf("exp %v, got %v", tc.expErr, actParseErr)
}
if tc.expErr {
return
}
actEvents, err := eventRepo.FindAll()
if err != nil {
t.Errorf("exp nil, got %v", err)

63
plan/command/argset.go Normal file
View File

@ -0,0 +1,63 @@
package command
import (
"fmt"
"time"
)
type ArgSet struct {
Main string
Flags map[string]Flag
}
func (as *ArgSet) Set(name, val string) error {
f, ok := as.Flags[name]
if !ok {
return fmt.Errorf("unknown flag %s", name)
}
return f.Set(val)
}
func (as *ArgSet) IsSet(name string) bool {
f, ok := as.Flags[name]
if !ok {
return false
}
return f.IsSet()
}
func (as *ArgSet) GetString(name string) string {
flag, ok := as.Flags[name]
if !ok {
return ""
}
val, ok := flag.Get().(string)
if !ok {
return ""
}
return val
}
func (as *ArgSet) GetTime(name string) time.Time {
flag, ok := as.Flags[name]
if !ok {
return time.Time{}
}
val, ok := flag.Get().(time.Time)
if !ok {
return time.Time{}
}
return val
}
func (as *ArgSet) GetDuration(name string) time.Duration {
flag, ok := as.Flags[name]
if !ok {
return time.Duration(0)
}
val, ok := flag.Get().(time.Duration)
if !ok {
return time.Duration(0)
}
return val
}

110
plan/command/argset_test.go Normal file
View File

@ -0,0 +1,110 @@
package command_test
import (
"testing"
"time"
"go-mod.ewintr.nl/planner/plan/command"
)
func TestArgSet(t *testing.T) {
for _, tt := range []struct {
name string
flags map[string]command.Flag
flagName string
setValue string
exp interface{}
expErr bool
}{
{
name: "string flag success",
flags: map[string]command.Flag{
"title": &command.FlagString{Name: "title"},
},
flagName: "title",
setValue: "test title",
exp: "test title",
},
{
name: "date flag success",
flags: map[string]command.Flag{
"date": &command.FlagDate{Name: "date"},
},
flagName: "date",
setValue: "2024-01-02",
exp: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
},
{
name: "time flag success",
flags: map[string]command.Flag{
"time": &command.FlagTime{Name: "time"},
},
flagName: "time",
setValue: "15:04",
exp: time.Date(0, 1, 1, 15, 4, 0, 0, time.UTC),
},
{
name: "duration flag success",
flags: map[string]command.Flag{
"duration": &command.FlagDuration{Name: "duration"},
},
flagName: "duration",
setValue: "2h30m",
exp: 2*time.Hour + 30*time.Minute,
},
{
name: "unknown flag error",
flags: map[string]command.Flag{},
flagName: "unknown",
setValue: "value",
expErr: true,
},
{
name: "invalid date format error",
flags: map[string]command.Flag{
"date": &command.FlagDate{Name: "date"},
},
flagName: "date",
setValue: "invalid",
expErr: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
as := &command.ArgSet{
Main: "test",
Flags: tt.flags,
}
err := as.Set(tt.flagName, tt.setValue)
if (err != nil) != tt.expErr {
t.Errorf("ArgSet.Set() error = %v, expErr %v", err, tt.expErr)
return
}
if tt.expErr {
return
}
// Verify IsSet() returns true after setting
if !as.IsSet(tt.flagName) {
t.Errorf("ArgSet.IsSet() = false, want true for flag %s", tt.flagName)
}
// Verify the value was set correctly based on flag type
switch v := tt.exp.(type) {
case string:
if got := as.GetString(tt.flagName); got != v {
t.Errorf("ArgSet.GetString() = %v, want %v", got, v)
}
case time.Time:
if got := as.GetTime(tt.flagName); !got.Equal(v) {
t.Errorf("ArgSet.GetTime() = %v, want %v", got, v)
}
case time.Duration:
if got := as.GetDuration(tt.flagName); got != v {
t.Errorf("ArgSet.GetDuration() = %v, want %v", got, v)
}
}
})
}
}

65
plan/command/command.go Normal file
View File

@ -0,0 +1,65 @@
package command
import (
"errors"
"fmt"
"strings"
)
const (
FlagTitle = "title"
FlagOn = "on"
FlagAt = "at"
FlagFor = "for"
)
type Command interface {
Execute([]string, map[string]string) error
}
type CLI struct {
Commands []Command
}
func (cli *CLI) Run(args []string) error {
main, flags, err := ParseFlags(args)
if err != nil {
return err
}
for _, c := range cli.Commands {
err := c.Execute(main, flags)
switch {
case errors.Is(err, ErrWrongCommand):
continue
case err != nil:
return err
}
}
return fmt.Errorf("could not find matching command")
}
func ParseFlags(args []string) ([]string, map[string]string, error) {
flags := make(map[string]string)
main := make([]string, 0)
var inMain bool
for i := 0; i < len(args); i++ {
if strings.HasPrefix(args[i], "-") {
inMain = false
if i+1 >= len(args) {
return nil, nil, fmt.Errorf("flag wihout value")
}
flags[strings.TrimPrefix(args[i], "-")] = args[i+1]
i++
continue
}
if !inMain && len(main) > 0 {
return nil, nil, fmt.Errorf("two mains")
}
inMain = true
main = append(main, args[i])
}
return main, flags, nil
}

View File

@ -0,0 +1,109 @@
package command_test
// func TestArgSet(t *testing.T) {
// t.Parallel()
// as := command.ArgSet{
// Main: "main",
// Flags: map[string]string{
// "name 1": "value 1",
// "name 2": "value 2",
// "name 3": "value 3",
// },
// }
// t.Run("hasflag", func(t *testing.T) {
// t.Run("true", func(t *testing.T) {
// if has := as.HasFlag("name 1"); !has {
// t.Errorf("exp true, got %v", has)
// }
// })
// t.Run("false", func(t *testing.T) {
// if has := as.HasFlag("unknown"); has {
// t.Errorf("exp false, got %v", has)
// }
// })
// })
// t.Run("flag", func(t *testing.T) {
// t.Run("known", func(t *testing.T) {
// if val := as.Flag("name 1"); val != "value 1" {
// t.Errorf("exp value 1, got %v", val)
// }
// })
// t.Run("unknown", func(t *testing.T) {
// if val := as.Flag("unknown"); val != "" {
// t.Errorf(`exp "", got %v`, val)
// }
// })
// })
// t.Run("setflag", func(t *testing.T) {
// exp := "new value"
// as.SetFlag("new name", exp)
// if act := as.Flag("new name"); exp != act {
// t.Errorf("exp %v, got %v", exp, act)
// }
// })
// }
// func TestParseArgs(t *testing.T) {
// t.Parallel()
// for _, tc := range []struct {
// name string
// args []string
// expAS *command.ArgSet
// expErr bool
// }{
// {
// name: "empty",
// expAS: &command.ArgSet{
// Flags: map[string]string{},
// },
// },
// {
// name: "just main",
// args: []string{"one", "two three", "four"},
// expAS: &command.ArgSet{
// Main: "one two three four",
// Flags: map[string]string{},
// },
// },
// {
// name: "with flags",
// args: []string{"-flag1", "value1", "one", "two", "-flag2", "value2", "-flag3", "value3"},
// expAS: &command.ArgSet{
// Main: "one two",
// Flags: map[string]string{
// "flag1": "value1",
// "flag2": "value2",
// "flag3": "value3",
// },
// },
// },
// {
// name: "flag without value",
// args: []string{"one", "two", "-flag1"},
// expErr: true,
// },
// {
// name: "split main",
// args: []string{"one", "-flag1", "value1", "two"},
// expErr: true,
// },
// } {
// t.Run(tc.name, func(t *testing.T) {
// actAS, actErr := command.ParseArgs(tc.args)
// if tc.expErr != (actErr != nil) {
// t.Errorf("exp %v, got %v", tc.expErr, actErr)
// }
// if tc.expErr {
// return
// }
// if diff := cmp.Diff(tc.expAS, actAS); diff != "" {
// t.Errorf("(exp +, got -)\n%s", diff)
// }
// })
// }
// }

View File

@ -2,39 +2,47 @@ package command
import (
"fmt"
"strconv"
"github.com/urfave/cli/v2"
"go-mod.ewintr.nl/planner/plan/storage"
)
var DeleteCmd = &cli.Command{
Name: "delete",
Usage: "Delete an event",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "localID",
Aliases: []string{"l"},
Usage: "The local id of the event",
Required: true,
},
},
type Delete struct {
localIDRepo storage.LocalID
eventRepo storage.Event
syncRepo storage.Sync
localID int
}
func NewDeleteCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command {
DeleteCmd.Action = func(cCtx *cli.Context) error {
return Delete(localRepo, eventRepo, syncRepo, cCtx.Int("localID"))
func NewDelete(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) Command {
return &Delete{
localIDRepo: localIDRepo,
eventRepo: eventRepo,
syncRepo: syncRepo,
}
return DeleteCmd
}
func Delete(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, localID int) error {
func (del *Delete) Execute(main []string, flags map[string]string) error {
if len(main) < 2 || main[0] != "delete" {
return ErrWrongCommand
}
localID, err := strconv.Atoi(main[1])
if err != nil {
return fmt.Errorf("not a local id: %v", main[1])
}
del.localID = localID
return del.do()
}
func (del *Delete) do() error {
var id string
idMap, err := localRepo.FindAll()
idMap, err := del.localIDRepo.FindAll()
if err != nil {
return fmt.Errorf("could not get local ids: %v", err)
}
for eid, lid := range idMap {
if localID == lid {
if del.localID == lid {
id = eid
}
}
@ -42,11 +50,7 @@ func Delete(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage
return fmt.Errorf("could not find local id")
}
if err := eventRepo.Delete(id); err != nil {
return fmt.Errorf("could not delete event: %v", err)
}
e, err := eventRepo.Find(id)
e, err := del.eventRepo.Find(id)
if err != nil {
return fmt.Errorf("could not get event: %v", err)
}
@ -55,8 +59,18 @@ func Delete(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage
if err != nil {
return fmt.Errorf("could not convert event to sync item: %v", err)
}
if err := syncRepo.Store(it); err != nil {
it.Deleted = true
if err := del.syncRepo.Store(it); err != nil {
return fmt.Errorf("could not store sync item: %v", err)
}
if err := del.localIDRepo.Delete(id); err != nil {
return fmt.Errorf("could not delete local id: %v", err)
}
if err := del.eventRepo.Delete(id); err != nil {
return fmt.Errorf("could not delete event: %v", err)
}
return nil
}

View File

@ -24,14 +24,24 @@ func TestDelete(t *testing.T) {
for _, tc := range []struct {
name string
localID int
main []string
flags map[string]string
expErr bool
}{
{
name: "not found",
localID: 5,
name: "invalid",
main: []string{"update"},
expErr: true,
},
{
name: "not found",
main: []string{"delete", "5"},
expErr: true,
},
{
name: "valid",
main: []string{"delete", "1"},
},
} {
t.Run(tc.name, func(t *testing.T) {
eventRepo := memory.NewEvent()
@ -44,7 +54,9 @@ func TestDelete(t *testing.T) {
t.Errorf("exp nil, got %v", err)
}
actErr := command.Delete(localRepo, eventRepo, syncRepo, tc.localID) != nil
cmd := command.NewDelete(localRepo, eventRepo, syncRepo)
actErr := cmd.Execute(tc.main, tc.flags) != nil
if tc.expErr != actErr {
t.Errorf("exp %v, got %v", tc.expErr, actErr)
}

109
plan/command/flag.go Normal file
View File

@ -0,0 +1,109 @@
package command
import (
"errors"
"fmt"
"time"
)
const (
DateFormat = "2006-01-02"
TimeFormat = "15:04"
)
var (
ErrWrongCommand = errors.New("wrong command")
ErrInvalidArg = errors.New("invalid argument")
)
type Flag interface {
Set(val string) error
IsSet() bool
Get() any
}
type FlagString struct {
Name string
Value string
}
func (fs *FlagString) Set(val string) error {
fs.Value = val
return nil
}
func (fs *FlagString) IsSet() bool {
return fs.Value != ""
}
func (fs *FlagString) Get() any {
return fs.Value
}
type FlagDate struct {
Name string
Value time.Time
}
func (ft *FlagDate) Set(val string) error {
d, err := time.Parse(DateFormat, val)
if err != nil {
return fmt.Errorf("could not parse date: %v", d)
}
ft.Value = d
return nil
}
func (ft *FlagDate) IsSet() bool {
return !ft.Value.IsZero()
}
func (fs *FlagDate) Get() any {
return fs.Value
}
type FlagTime struct {
Name string
Value time.Time
}
func (ft *FlagTime) Set(val string) error {
d, err := time.Parse(TimeFormat, val)
if err != nil {
return fmt.Errorf("could not parse date: %v", d)
}
ft.Value = d
return nil
}
func (fd *FlagTime) IsSet() bool {
return !fd.Value.IsZero()
}
func (fs *FlagTime) Get() any {
return fs.Value
}
type FlagDuration struct {
Name string
Value time.Duration
}
func (fd *FlagDuration) Set(val string) error {
dur, err := time.ParseDuration(val)
if err != nil {
return fmt.Errorf("could not parse duration: %v", err)
}
fd.Value = dur
return nil
}
func (fd *FlagDuration) IsSet() bool {
return fd.Value.String() != "0s"
}
func (fs *FlagDuration) Get() any {
return fs.Value
}

115
plan/command/flag_test.go Normal file
View File

@ -0,0 +1,115 @@
package command_test
import (
"testing"
"time"
"go-mod.ewintr.nl/planner/plan/command"
)
func TestFlagString(t *testing.T) {
t.Parallel()
valid := "test"
f := command.FlagString{}
if f.IsSet() {
t.Errorf("exp false, got true")
}
if err := f.Set(valid); err != nil {
t.Errorf("exp nil, got %v", err)
}
if !f.IsSet() {
t.Errorf("exp true, got false")
}
act, ok := f.Get().(string)
if !ok {
t.Errorf("exp true, got false")
}
if act != valid {
t.Errorf("exp %v, got %v", valid, act)
}
}
func TestFlagDate(t *testing.T) {
t.Parallel()
valid := time.Date(2024, 11, 20, 0, 0, 0, 0, time.UTC)
validStr := "2024-11-20"
f := command.FlagDate{}
if f.IsSet() {
t.Errorf("exp false, got true")
}
if err := f.Set(validStr); err != nil {
t.Errorf("exp nil, got %v", err)
}
if !f.IsSet() {
t.Errorf("exp true, got false")
}
act, ok := f.Get().(time.Time)
if !ok {
t.Errorf("exp true, got false")
}
if act != valid {
t.Errorf("exp %v, got %v", valid, act)
}
}
func TestFlagTime(t *testing.T) {
t.Parallel()
valid := time.Date(0, 1, 1, 12, 30, 0, 0, time.UTC)
validStr := "12:30"
f := command.FlagTime{}
if f.IsSet() {
t.Errorf("exp false, got true")
}
if err := f.Set(validStr); err != nil {
t.Errorf("exp nil, got %v", err)
}
if !f.IsSet() {
t.Errorf("exp true, got false")
}
act, ok := f.Get().(time.Time)
if !ok {
t.Errorf("exp true, got false")
}
if act != valid {
t.Errorf("exp %v, got %v", valid, act)
}
}
func TestFlagDurationTime(t *testing.T) {
t.Parallel()
valid := time.Hour
validStr := "1h"
f := command.FlagDuration{}
if f.IsSet() {
t.Errorf("exp false, got true")
}
if err := f.Set(validStr); err != nil {
t.Errorf("exp nil, got %v", err)
}
if !f.IsSet() {
t.Errorf("exp true, got false")
}
act, ok := f.Get().(time.Duration)
if !ok {
t.Errorf("exp true, got false")
}
if act != valid {
t.Errorf("exp %v, got %v", valid, act)
}
}

View File

@ -4,28 +4,35 @@ import (
"fmt"
"time"
"github.com/urfave/cli/v2"
"go-mod.ewintr.nl/planner/plan/storage"
)
var ListCmd = &cli.Command{
Name: "list",
Usage: "List everything",
type List struct {
localIDRepo storage.LocalID
eventRepo storage.Event
}
func NewListCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command {
ListCmd.Action = func(cCtx *cli.Context) error {
return List(localRepo, eventRepo)
func NewList(localIDRepo storage.LocalID, eventRepo storage.Event) Command {
return &List{
localIDRepo: localIDRepo,
eventRepo: eventRepo,
}
return ListCmd
}
func List(localRepo storage.LocalID, eventRepo storage.Event) error {
localIDs, err := localRepo.FindAll()
func (list *List) Execute(main []string, flags map[string]string) error {
if len(main) > 0 && main[0] != "list" {
return ErrWrongCommand
}
return list.do()
}
func (list *List) do() error {
localIDs, err := list.localIDRepo.FindAll()
if err != nil {
return fmt.Errorf("could not get local ids: %v", err)
}
all, err := eventRepo.FindAll()
all, err := list.eventRepo.FindAll()
if err != nil {
return err
}

58
plan/command/list_test.go Normal file
View File

@ -0,0 +1,58 @@
package command_test
import (
"testing"
"time"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command"
"go-mod.ewintr.nl/planner/plan/storage/memory"
)
func TestList(t *testing.T) {
t.Parallel()
eventRepo := memory.NewEvent()
localRepo := memory.NewLocalID()
e := item.Event{
ID: "id",
EventBody: item.EventBody{
Title: "name",
Start: time.Date(2024, 10, 7, 9, 30, 0, 0, time.UTC),
},
}
if err := eventRepo.Store(e); err != nil {
t.Errorf("exp nil, got %v", err)
}
if err := localRepo.Store(e.ID, 1); err != nil {
t.Errorf("exp nil, got %v", err)
}
for _, tc := range []struct {
name string
main []string
expErr bool
}{
{
name: "empty",
main: []string{},
},
{
name: "list",
main: []string{"list"},
},
{
name: "wrong",
main: []string{"delete"},
expErr: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := command.NewList(localRepo, eventRepo)
actErr := cmd.Execute(tc.main, nil) != nil
if tc.expErr != actErr {
t.Errorf("exp %v, got %v", tc.expErr, actErr)
}
})
}
}

1
plan/command/show.go Normal file
View File

@ -0,0 +1 @@
package command

View File

@ -5,50 +5,54 @@ import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/storage"
"go-mod.ewintr.nl/planner/sync/client"
)
var SyncCmd = &cli.Command{
Name: "sync",
Usage: "Synchronize with server",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "full",
Aliases: []string{"f"},
Usage: "Force full sync",
},
},
type Sync struct {
client client.Client
syncRepo storage.Sync
localIDRepo storage.LocalID
eventRepo storage.Event
}
func NewSyncCmd(client client.Client, syncRepo storage.Sync, localIDRepo storage.LocalID, eventRepo storage.Event) *cli.Command {
SyncCmd.Action = func(cCtx *cli.Context) error {
return Sync(client, syncRepo, localIDRepo, eventRepo, cCtx.Bool("full"))
func NewSync(client client.Client, syncRepo storage.Sync, localIDRepo storage.LocalID, eventRepo storage.Event) Command {
return &Sync{
client: client,
syncRepo: syncRepo,
localIDRepo: localIDRepo,
eventRepo: eventRepo,
}
return SyncCmd
}
func Sync(client client.Client, syncRepo storage.Sync, localIDRepo storage.LocalID, eventRepo storage.Event, full bool) error {
func (sync *Sync) Execute(main []string, flags map[string]string) error {
if len(main) == 0 || main[0] != "sync" {
return ErrWrongCommand
}
return sync.do()
}
func (sync *Sync) do() error {
// local new and updated
sendItems, err := syncRepo.FindAll()
sendItems, err := sync.syncRepo.FindAll()
if err != nil {
return fmt.Errorf("could not get updated items: %v", err)
}
if err := client.Update(sendItems); err != nil {
if err := sync.client.Update(sendItems); err != nil {
return fmt.Errorf("could not send updated items: %v", err)
}
if err := syncRepo.DeleteAll(); err != nil {
if err := sync.syncRepo.DeleteAll(); err != nil {
return fmt.Errorf("could not clear updated items: %v", err)
}
// get new/updated items
ts, err := syncRepo.LastUpdate()
ts, err := sync.syncRepo.LastUpdate()
if err != nil {
return fmt.Errorf("could not find timestamp of last update: %v", err)
}
recItems, err := client.Updated([]item.Kind{item.KindEvent}, ts)
recItems, err := sync.client.Updated([]item.Kind{item.KindEvent}, ts)
if err != nil {
return fmt.Errorf("could not receive updates: %v", err)
}
@ -56,10 +60,10 @@ func Sync(client client.Client, syncRepo storage.Sync, localIDRepo storage.Local
updated := make([]item.Item, 0)
for _, ri := range recItems {
if ri.Deleted {
if err := localIDRepo.Delete(ri.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
if err := sync.localIDRepo.Delete(ri.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("could not delete local id: %v", err)
}
if err := eventRepo.Delete(ri.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
if err := sync.eventRepo.Delete(ri.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("could not delete event: %v", err)
}
continue
@ -67,7 +71,7 @@ func Sync(client client.Client, syncRepo storage.Sync, localIDRepo storage.Local
updated = append(updated, ri)
}
lidMap, err := localIDRepo.FindAll()
lidMap, err := sync.localIDRepo.FindAll()
if err != nil {
return fmt.Errorf("could not get local ids: %v", err)
}
@ -80,17 +84,17 @@ func Sync(client client.Client, syncRepo storage.Sync, localIDRepo storage.Local
ID: u.ID,
EventBody: eBody,
}
if err := eventRepo.Store(e); err != nil {
if err := sync.eventRepo.Store(e); err != nil {
return fmt.Errorf("could not store event: %v", err)
}
lid, ok := lidMap[u.ID]
if !ok {
lid, err = localIDRepo.Next()
lid, err = sync.localIDRepo.Next()
if err != nil {
return fmt.Errorf("could not get next local id: %v", err)
}
if err := localIDRepo.Store(u.ID, lid); err != nil {
if err := sync.localIDRepo.Store(u.ID, lid); err != nil {
return fmt.Errorf("could not store local id: %v", err)
}
}

View File

@ -11,6 +11,43 @@ import (
"go-mod.ewintr.nl/planner/sync/client"
)
func TestSyncParse(t *testing.T) {
t.Parallel()
syncClient := client.NewMemory()
syncRepo := memory.NewSync()
localIDRepo := memory.NewLocalID()
eventRepo := memory.NewEvent()
for _, tc := range []struct {
name string
main []string
expErr bool
}{
{
name: "empty",
expErr: true,
},
{
name: "wrong",
main: []string{"wrong"},
expErr: true,
},
{
name: "valid",
main: []string{"sync"},
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := command.NewSync(syncClient, syncRepo, localIDRepo, eventRepo)
actErr := cmd.Execute(tc.main, nil) != nil
if tc.expErr != actErr {
t.Errorf("exp %v, got %v", tc.expErr, actErr)
}
})
}
}
func TestSyncSend(t *testing.T) {
t.Parallel()
@ -45,8 +82,8 @@ func TestSyncSend(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
if err := command.Sync(syncClient, syncRepo, localIDRepo, eventRepo, false); err != nil {
cmd := command.NewSync(syncClient, syncRepo, localIDRepo, eventRepo)
if err := cmd.Execute([]string{"sync"}, nil); err != nil {
t.Errorf("exp nil, got %v", err)
}
actItems, actErr := syncClient.Updated(tc.ks, tc.ts)
@ -163,7 +200,8 @@ func TestSyncReceive(t *testing.T) {
}
// sync
if err := command.Sync(syncClient, syncRepo, localIDRepo, eventRepo, false); err != nil {
cmd := command.NewSync(syncClient, syncRepo, localIDRepo, eventRepo)
if err := cmd.Execute([]string{"sync"}, nil); err != nil {
t.Errorf("exp nil, got %v", err)
}

View File

@ -2,60 +2,73 @@ package command
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/urfave/cli/v2"
"go-mod.ewintr.nl/planner/plan/storage"
)
var UpdateCmd = &cli.Command{
Name: "update",
Usage: "Update an event",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "localID",
Aliases: []string{"l"},
Usage: "The local id of the event",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "The event that will happen",
},
&cli.StringFlag{
Name: "on",
Aliases: []string{"o"},
Usage: "The date, in YYYY-MM-DD format",
},
&cli.StringFlag{
Name: "at",
Aliases: []string{"a"},
Usage: "The time, in HH:MM format. If omitted, the event will last the whole day",
},
&cli.StringFlag{
Name: "for",
Aliases: []string{"f"},
Usage: "The duration, in show format (e.g. 1h30m)",
},
},
type Update struct {
localIDRepo storage.LocalID
eventRepo storage.Event
syncRepo storage.Sync
argSet *ArgSet
localID int
}
func NewUpdateCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command {
UpdateCmd.Action = func(cCtx *cli.Context) error {
return Update(localRepo, eventRepo, syncRepo, cCtx.Int("localID"), cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for"))
func NewUpdate(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) Command {
return &Update{
localIDRepo: localIDRepo,
eventRepo: eventRepo,
syncRepo: syncRepo,
argSet: &ArgSet{
Flags: map[string]Flag{
FlagTitle: &FlagString{},
FlagOn: &FlagDate{},
FlagAt: &FlagTime{},
FlagFor: &FlagDuration{},
},
},
}
return UpdateCmd
}
func Update(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, localID int, nameStr, onStr, atStr, frStr string) error {
func (update *Update) Execute(main []string, flags map[string]string) error {
if len(main) < 2 || main[0] != "update" {
return ErrWrongCommand
}
localID, err := strconv.Atoi(main[1])
if err != nil {
return fmt.Errorf("not a local id: %v", main[1])
}
update.localID = localID
main = main[2:]
as := update.argSet
as.Main = strings.Join(main, " ")
for k := range as.Flags {
v, ok := flags[k]
if !ok {
continue
}
if err := as.Set(k, v); err != nil {
return fmt.Errorf("could not set %s: %v", k, err)
}
}
update.argSet = as
return update.do()
}
func (update *Update) do() error {
as := update.argSet
var id string
idMap, err := localRepo.FindAll()
idMap, err := update.localIDRepo.FindAll()
if err != nil {
return fmt.Errorf("could not get local ids: %v", err)
}
for eid, lid := range idMap {
if localID == lid {
if update.localID == lid {
id = eid
}
}
@ -63,39 +76,39 @@ func Update(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage
return fmt.Errorf("could not find local id")
}
e, err := eventRepo.Find(id)
e, err := update.eventRepo.Find(id)
if err != nil {
return fmt.Errorf("could not find event")
}
if nameStr != "" {
e.Title = nameStr
if as.Main != "" {
e.Title = as.Main
}
if onStr != "" || atStr != "" {
oldStart := e.Start
dateStr := oldStart.Format("2006-01-02")
if onStr != "" {
dateStr = onStr
if as.IsSet(FlagOn) || as.IsSet(FlagAt) {
on := time.Date(e.Start.Year(), e.Start.Month(), e.Start.Day(), 0, 0, 0, 0, time.UTC)
atH := time.Duration(e.Start.Hour()) * time.Hour
atM := time.Duration(e.Start.Minute()) * time.Minute
if as.IsSet(FlagOn) {
on = as.GetTime(FlagOn)
}
timeStr := oldStart.Format("15:04")
if atStr != "" {
timeStr = atStr
if as.IsSet(FlagAt) {
at := as.GetTime(FlagAt)
atH = time.Duration(at.Hour()) * time.Hour
atM = time.Duration(at.Minute()) * time.Minute
}
newStart, err := time.Parse("2006-01-02 15:04", fmt.Sprintf("%s %s", dateStr, timeStr))
if err != nil {
return fmt.Errorf("could not parse new start: %v", err)
}
e.Start = newStart
e.Start = on.Add(atH).Add(atM)
}
if frStr != "" { // no check on at, can set a duration with at 00:00, making it not a whole day
fr, err := time.ParseDuration(frStr)
if err != nil {
return fmt.Errorf("%w: could not parse duration: %s", ErrInvalidArg, err)
if as.IsSet(FlagFor) {
e.Duration = as.GetDuration(FlagFor)
}
e.Duration = fr
if !e.Valid() {
return fmt.Errorf("event is unvalid")
}
if err := eventRepo.Store(e); err != nil {
if err := update.eventRepo.Store(e); err != nil {
return fmt.Errorf("could not store event: %v", err)
}
@ -103,7 +116,7 @@ func Update(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage
if err != nil {
return fmt.Errorf("could not convert event to sync item: %v", err)
}
if err := syncRepo.Store(it); err != nil {
if err := update.syncRepo.Store(it); err != nil {
return fmt.Errorf("could not store sync item: %v", err)
}

View File

@ -1,6 +1,7 @@
package command_test
import (
"fmt"
"testing"
"time"
@ -10,7 +11,7 @@ import (
"go-mod.ewintr.nl/planner/plan/storage/memory"
)
func TestUpdate(t *testing.T) {
func TestUpdateExecute(t *testing.T) {
t.Parallel()
eid := "c"
@ -29,21 +30,14 @@ func TestUpdate(t *testing.T) {
for _, tc := range []struct {
name string
localID int
args map[string]string
main []string
flags map[string]string
expEvent item.Event
expErr bool
}{
{
name: "no args",
localID: lid,
expEvent: item.Event{
ID: eid,
EventBody: item.EventBody{
Title: title,
Start: start,
Duration: oneHour,
},
},
expErr: true,
},
{
name: "not found",
@ -53,9 +47,7 @@ func TestUpdate(t *testing.T) {
{
name: "name",
localID: lid,
args: map[string]string{
"name": "updated",
},
main: []string{"update", fmt.Sprintf("%d", lid), "updated"},
expEvent: item.Event{
ID: eid,
EventBody: item.EventBody{
@ -68,7 +60,8 @@ func TestUpdate(t *testing.T) {
{
name: "invalid on",
localID: lid,
args: map[string]string{
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"on": "invalid",
},
expErr: true,
@ -76,7 +69,8 @@ func TestUpdate(t *testing.T) {
{
name: "on",
localID: lid,
args: map[string]string{
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"on": "2024-10-02",
},
expEvent: item.Event{
@ -91,7 +85,8 @@ func TestUpdate(t *testing.T) {
{
name: "invalid at",
localID: lid,
args: map[string]string{
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"at": "invalid",
},
expErr: true,
@ -99,7 +94,8 @@ func TestUpdate(t *testing.T) {
{
name: "at",
localID: lid,
args: map[string]string{
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"at": "11:00",
},
expEvent: item.Event{
@ -114,7 +110,8 @@ func TestUpdate(t *testing.T) {
{
name: "on and at",
localID: lid,
args: map[string]string{
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"on": "2024-10-02",
"at": "11:00",
},
@ -130,7 +127,8 @@ func TestUpdate(t *testing.T) {
{
name: "invalid for",
localID: lid,
args: map[string]string{
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"for": "invalid",
},
expErr: true,
@ -138,7 +136,8 @@ func TestUpdate(t *testing.T) {
{
name: "for",
localID: lid,
args: map[string]string{
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"for": "2h",
},
expEvent: item.Event{
@ -169,9 +168,10 @@ func TestUpdate(t *testing.T) {
t.Errorf("exp nil, ,got %v", err)
}
actErr := command.Update(localIDRepo, eventRepo, syncRepo, tc.localID, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil
if tc.expErr != actErr {
t.Errorf("exp %v, got %v", tc.expErr, actErr)
cmd := command.NewUpdate(localIDRepo, eventRepo, syncRepo)
actParseErr := cmd.Execute(tc.main, tc.flags) != nil
if tc.expErr != actParseErr {
t.Errorf("exp %v, got %v", tc.expErr, actParseErr)
}
if tc.expErr {
return

View File

@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"github.com/urfave/cli/v2"
"go-mod.ewintr.nl/planner/plan/command"
"go-mod.ewintr.nl/planner/plan/storage/sqlite"
"go-mod.ewintr.nl/planner/sync/client"
@ -32,19 +31,17 @@ func main() {
syncClient := client.New(conf.SyncURL, conf.ApiKey)
app := &cli.App{
Name: "plan",
Usage: "Plan your day with events",
Commands: []*cli.Command{
command.NewAddCmd(localIDRepo, eventRepo, syncRepo),
command.NewListCmd(localIDRepo, eventRepo),
command.NewUpdateCmd(localIDRepo, eventRepo, syncRepo),
command.NewDeleteCmd(localIDRepo, eventRepo, syncRepo),
command.NewSyncCmd(syncClient, syncRepo, localIDRepo, eventRepo),
cli := command.CLI{
Commands: []command.Command{
command.NewAdd(localIDRepo, eventRepo, syncRepo),
command.NewList(localIDRepo, eventRepo),
command.NewUpdate(localIDRepo, eventRepo, syncRepo),
command.NewDelete(localIDRepo, eventRepo, syncRepo),
command.NewSync(syncClient, syncRepo, localIDRepo, eventRepo),
},
}
if err := app.Run(os.Args); err != nil {
if err := cli.Run(os.Args); err != nil {
fmt.Println(err)
os.Exit(1)
}

View File

@ -1,11 +1,11 @@
package memory
import (
"errors"
"sort"
"sync"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/storage"
)
type Event struct {
@ -25,7 +25,7 @@ func (r *Event) Find(id string) (item.Event, error) {
event, exists := r.events[id]
if !exists {
return item.Event{}, errors.New("event not found")
return item.Event{}, storage.ErrNotFound
}
return event, nil
}
@ -59,7 +59,7 @@ func (r *Event) Delete(id string) error {
defer r.mutex.Unlock()
if _, exists := r.events[id]; !exists {
return errors.New("event not found")
return storage.ErrNotFound
}
delete(r.events, id)