diff --git a/plan/command/add.go b/plan/command/add.go index 0e8724a..8f07e46 100644 --- a/plan/command/add.go +++ b/plan/command/add.go @@ -44,14 +44,14 @@ var AddCmd = &cli.Command{ }, } -func NewAddCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command { +func NewAddCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command { AddCmd.Action = func(cCtx *cli.Context) error { - return Add(localRepo, eventRepo, cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for")) + return Add(localRepo, eventRepo, syncRepo, cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for")) } return AddCmd } -func Add(localIDRepo storage.LocalID, eventRepo storage.Event, nameStr, onStr, atStr, frStr string) error { +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) } @@ -103,5 +103,13 @@ func Add(localIDRepo storage.LocalID, eventRepo storage.Event, nameStr, onStr, a return fmt.Errorf("could not store local id: %v", err) } + it, err := e.Item() + if err != nil { + return fmt.Errorf("could not convert event to sync item: %v", err) + } + if err := syncRepo.Store(it); err != nil { + return fmt.Errorf("could not store sync item: %v", err) + } + return nil } diff --git a/plan/command/add_test.go b/plan/command/add_test.go index 8c9a426..b66826c 100644 --- a/plan/command/add_test.go +++ b/plan/command/add_test.go @@ -106,7 +106,8 @@ func TestAdd(t *testing.T) { t.Run(tc.name, func(t *testing.T) { eventRepo := memory.NewEvent() localRepo := memory.NewLocalID() - actErr := command.Add(localRepo, eventRepo, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil + 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) } @@ -139,6 +140,14 @@ func TestAdd(t *testing.T) { if diff := cmp.Diff(tc.expEvent, actEvents[0]); diff != "" { t.Errorf("(exp +, got -)\n%s", diff) } + + updated, err := syncRepo.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/delete.go b/plan/command/delete.go index 4dfdb58..8e35b14 100644 --- a/plan/command/delete.go +++ b/plan/command/delete.go @@ -20,14 +20,14 @@ var DeleteCmd = &cli.Command{ }, } -func NewDeleteCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command { +func NewDeleteCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command { DeleteCmd.Action = func(cCtx *cli.Context) error { - return Delete(localRepo, eventRepo, cCtx.Int("localID")) + return Delete(localRepo, eventRepo, syncRepo, cCtx.Int("localID")) } return DeleteCmd } -func Delete(localRepo storage.LocalID, eventRepo storage.Event, localID int) error { +func Delete(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, localID int) error { var id string idMap, err := localRepo.FindAll() if err != nil { @@ -46,5 +46,17 @@ func Delete(localRepo storage.LocalID, eventRepo storage.Event, localID int) err return fmt.Errorf("could not delete event: %v", err) } + e, err := eventRepo.Find(id) + if err != nil { + return fmt.Errorf("could not get event: %v", err) + } + + it, err := e.Item() + if err != nil { + return fmt.Errorf("could not convert event to sync item: %v", err) + } + if err := syncRepo.Store(it); err != nil { + return fmt.Errorf("could not store sync item: %v", err) + } return nil } diff --git a/plan/command/delete_test.go b/plan/command/delete_test.go index 5357608..772562a 100644 --- a/plan/command/delete_test.go +++ b/plan/command/delete_test.go @@ -35,6 +35,7 @@ func TestDelete(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { eventRepo := memory.NewEvent() + syncRepo := memory.NewSync() if err := eventRepo.Store(e); err != nil { t.Errorf("exp nil, got %v", err) } @@ -43,7 +44,7 @@ func TestDelete(t *testing.T) { t.Errorf("exp nil, got %v", err) } - actErr := command.Delete(localRepo, eventRepo, tc.localID) != nil + actErr := command.Delete(localRepo, eventRepo, syncRepo, tc.localID) != nil if tc.expErr != actErr { t.Errorf("exp %v, got %v", tc.expErr, actErr) } @@ -62,6 +63,13 @@ func TestDelete(t *testing.T) { if len(idMap) != 0 { t.Errorf("exp 0, got %v", len(idMap)) } + updated, err := syncRepo.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/sync.go b/plan/command/sync.go new file mode 100644 index 0000000..a8922e7 --- /dev/null +++ b/plan/command/sync.go @@ -0,0 +1,100 @@ +package command + +import ( + "encoding/json" + "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", + }, + }, +} + +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")) + } + return SyncCmd +} + +func Sync(client client.Client, syncRepo storage.Sync, localIDRepo storage.LocalID, eventRepo storage.Event, full bool) error { + // local new and updated + sendItems, err := syncRepo.FindAll() + if err != nil { + return fmt.Errorf("could not get updated items: %v", err) + } + if err := client.Update(sendItems); err != nil { + return fmt.Errorf("could not send updated items: %v", err) + } + if err := syncRepo.DeleteAll(); err != nil { + return fmt.Errorf("could not clear updated items: %v", err) + } + + // get new/updated items + ts, err := 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) + if err != nil { + return fmt.Errorf("could not receive updates: %v", err) + } + + updated := make([]item.Item, 0) + for _, ri := range recItems { + if ri.Deleted { + if err := localIDRepo.Delete(ri.ID); err != nil { + return fmt.Errorf("could not delete local id: %v", err) + } + if err := eventRepo.Delete(ri.ID); err != nil && !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("could not delete event: %v", err) + } + continue + } + updated = append(updated, ri) + } + + lidMap, err := localIDRepo.FindAll() + if err != nil { + return fmt.Errorf("could not get local ids: %v", err) + } + for _, u := range updated { + var eBody item.EventBody + if err := json.Unmarshal([]byte(u.Body), &eBody); err != nil { + return fmt.Errorf("could not unmarshal event body: %v", err) + } + e := item.Event{ + ID: u.ID, + EventBody: eBody, + } + if err := 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() + if err != nil { + return fmt.Errorf("could not get next local id: %v", err) + } + + if err := localIDRepo.Store(u.ID, lid); err != nil { + return fmt.Errorf("could not store local id: %v", err) + } + } + } + + return nil +} diff --git a/plan/command/sync_test.go b/plan/command/sync_test.go new file mode 100644 index 0000000..cd0935b --- /dev/null +++ b/plan/command/sync_test.go @@ -0,0 +1,187 @@ +package command_test + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/command" + "go-mod.ewintr.nl/planner/plan/storage/memory" + "go-mod.ewintr.nl/planner/sync/client" +) + +func TestSyncSend(t *testing.T) { + t.Parallel() + + syncClient := client.NewMemory() + syncRepo := memory.NewSync() + localIDRepo := memory.NewLocalID() + eventRepo := memory.NewEvent() + + it := item.Item{ + ID: "a", + Kind: item.KindEvent, + Body: `{ + "title":"title", + "start":"2024-10-18T08:00:00Z", + "duration":"1h" +}`, + } + if err := syncRepo.Store(it); err != nil { + t.Errorf("exp nil, got %v", err) + } + + for _, tc := range []struct { + name string + ks []item.Kind + ts time.Time + expItems []item.Item + }{ + { + name: "single", + ks: []item.Kind{item.KindEvent}, + expItems: []item.Item{it}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + + if err := command.Sync(syncClient, syncRepo, localIDRepo, eventRepo, false); err != nil { + t.Errorf("exp nil, got %v", err) + } + actItems, actErr := syncClient.Updated(tc.ks, tc.ts) + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if diff := cmp.Diff(tc.expItems, actItems); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) + } + + actLeft, actErr := syncRepo.FindAll() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if len(actLeft) != 0 { + t.Errorf("exp 0, got %v", actLeft) + } + }) + } +} + +func TestSyncReceive(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 + present []item.Event + updated []item.Item + expEvent []item.Event + expLocalID map[string]int + }{ + { + name: "no new", + expEvent: []item.Event{}, + expLocalID: map[string]int{}, + }, + { + name: "new", + updated: []item.Item{{ + ID: "a", + Kind: item.KindEvent, + Body: `{ + "title":"title", + "start":"2024-10-23T08:00:00Z", + "duration":"1h" +}`, + }}, + expEvent: []item.Event{{ + ID: "a", + EventBody: item.EventBody{ + Title: "title", + Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC), + Duration: oneHour, + }, + }}, + expLocalID: map[string]int{ + "a": 1, + }, + }, + { + name: "update existing", + present: []item.Event{{ + ID: "a", + EventBody: item.EventBody{ + Title: "title", + Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC), + Duration: oneHour, + }, + }}, + updated: []item.Item{{ + ID: "a", + Kind: item.KindEvent, + Body: `{ + "title":"new title", + "start":"2024-10-23T08:00:00Z", + "duration":"1h" +}`, + }}, + expEvent: []item.Event{{ + ID: "a", + EventBody: item.EventBody{ + Title: "new title", + Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC), + Duration: oneHour, + }, + }}, + expLocalID: map[string]int{ + "a": 1, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // setup + syncClient := client.NewMemory() + syncRepo := memory.NewSync() + localIDRepo := memory.NewLocalID() + eventRepo := memory.NewEvent() + + for i, p := range tc.present { + if err := eventRepo.Store(p); err != nil { + t.Errorf("exp nil, got %v", err) + } + if err := localIDRepo.Store(p.ID, i+1); err != nil { + t.Errorf("exp nil, got %v", err) + } + } + if err := syncClient.Update(tc.updated); err != nil { + t.Errorf("exp nil, got %v", err) + } + + // sync + if err := command.Sync(syncClient, syncRepo, localIDRepo, eventRepo, false); err != nil { + t.Errorf("exp nil, got %v", err) + } + + // check result + actEvents, err := eventRepo.FindAll() + if err != nil { + t.Errorf("exp nil, got %v", err) + } + if diff := cmp.Diff(tc.expEvent, actEvents); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) + } + actLocalIDs, err := localIDRepo.FindAll() + if err != nil { + t.Errorf("exp nil, got %v", err) + } + if diff := cmp.Diff(tc.expLocalID, actLocalIDs); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) + } + }) + } +} diff --git a/plan/command/update.go b/plan/command/update.go index e013251..883875a 100644 --- a/plan/command/update.go +++ b/plan/command/update.go @@ -41,14 +41,14 @@ var UpdateCmd = &cli.Command{ }, } -func NewUpdateCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command { +func NewUpdateCmd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync) *cli.Command { UpdateCmd.Action = func(cCtx *cli.Context) error { - return Update(localRepo, eventRepo, cCtx.Int("localID"), cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for")) + return Update(localRepo, eventRepo, syncRepo, cCtx.Int("localID"), cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for")) } return UpdateCmd } -func Update(localRepo storage.LocalID, eventRepo storage.Event, localID int, nameStr, onStr, atStr, frStr string) error { +func Update(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage.Sync, localID int, nameStr, onStr, atStr, frStr string) error { var id string idMap, err := localRepo.FindAll() if err != nil { @@ -99,5 +99,13 @@ func Update(localRepo storage.LocalID, eventRepo storage.Event, localID int, nam return fmt.Errorf("could not store event: %v", err) } + it, err := e.Item() + if err != nil { + return fmt.Errorf("could not convert event to sync item: %v", err) + } + if err := syncRepo.Store(it); err != nil { + return fmt.Errorf("could not store sync item: %v", err) + } + return nil } diff --git a/plan/command/update_test.go b/plan/command/update_test.go index 4f4877b..090a297 100644 --- a/plan/command/update_test.go +++ b/plan/command/update_test.go @@ -154,6 +154,7 @@ func TestUpdate(t *testing.T) { t.Run(tc.name, func(t *testing.T) { eventRepo := memory.NewEvent() localIDRepo := memory.NewLocalID() + syncRepo := memory.NewSync() if err := eventRepo.Store(item.Event{ ID: eid, EventBody: item.EventBody{ @@ -168,7 +169,7 @@ func TestUpdate(t *testing.T) { t.Errorf("exp nil, ,got %v", err) } - actErr := command.Update(localIDRepo, eventRepo, tc.localID, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil + 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) } @@ -183,7 +184,13 @@ func TestUpdate(t *testing.T) { if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" { t.Errorf("(exp +, got -)\n%s", diff) } - + updated, err := syncRepo.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/main.go b/plan/main.go index bb6b8e7..d48a2e0 100644 --- a/plan/main.go +++ b/plan/main.go @@ -8,6 +8,7 @@ import ( "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" "gopkg.in/yaml.v3" ) @@ -23,20 +24,23 @@ func main() { os.Exit(1) } - localIDRepo, eventRepo, err := sqlite.NewSqlites(conf.DBPath) + localIDRepo, eventRepo, syncRepo, err := sqlite.NewSqlites(conf.DBPath) if err != nil { fmt.Printf("could not open db file: %s\n", err) os.Exit(1) } + 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), + command.NewAddCmd(localIDRepo, eventRepo, syncRepo), command.NewListCmd(localIDRepo, eventRepo), - command.NewUpdateCmd(localIDRepo, eventRepo), - command.NewDeleteCmd(localIDRepo, eventRepo), + command.NewUpdateCmd(localIDRepo, eventRepo, syncRepo), + command.NewDeleteCmd(localIDRepo, eventRepo, syncRepo), + command.NewSyncCmd(syncClient, syncRepo, localIDRepo, eventRepo), }, } @@ -44,46 +48,12 @@ func main() { fmt.Println(err) os.Exit(1) } - - // all, err := repo.FindAll() - // if err != nil { - // fmt.Println(err) - // os.Exit(1) - // } - - // fmt.Printf("all: %+v\n", all) - - // c := client.NewClient("http://localhost:8092", "testKey") - // items, err := c.Updated([]item.Kind{item.KindEvent}, time.Time{}) - // if err != nil { - // fmt.Println(err) - // os.Exit(1) - // } - - // fmt.Printf("%+v\n", items) - - // i := item.Item{ - // ID: "id-1", - // Kind: item.KindEvent, - // Updated: time.Now(), - // Body: "body", - // } - // if err := c.Update([]item.Item{i}); err != nil { - // fmt.Println(err) - // os.Exit(1) - // } - - // items, err = c.Updated([]item.Kind{item.KindEvent}, time.Time{}) - // if err != nil { - // fmt.Println(err) - // os.Exit(1) - // } - - // fmt.Printf("%+v\n", items) } type Configuration struct { - DBPath string `yaml:"dbpath"` + DBPath string `yaml:"dbpath"` + SyncURL string `yaml:"sync_url"` + ApiKey string `yaml:"api_key"` } func LoadConfig(path string) (Configuration, error) { diff --git a/plan/storage/memory/localid.go b/plan/storage/memory/localid.go index 40024b1..ece7ba1 100644 --- a/plan/storage/memory/localid.go +++ b/plan/storage/memory/localid.go @@ -1,6 +1,7 @@ package memory import ( + "errors" "sync" "go-mod.ewintr.nl/planner/plan/storage" @@ -24,6 +25,30 @@ func (ml *LocalID) FindAll() (map[string]int, error) { return ml.ids, nil } +func (ml *LocalID) Find(id string) (int, error) { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + lid, ok := ml.ids[id] + if !ok { + return 0, storage.ErrNotFound + } + + return lid, nil +} + +func (ml *LocalID) FindOrNext(id string) (int, error) { + lid, err := ml.Find(id) + switch { + case errors.Is(err, storage.ErrNotFound): + return ml.Next() + case err != nil: + return 0, err + default: + return lid, nil + } +} + func (ml *LocalID) Next() (int, error) { ml.mutex.RLock() defer ml.mutex.RUnlock() diff --git a/plan/storage/memory/localid_test.go b/plan/storage/memory/localid_test.go index 02d060e..eddaf6f 100644 --- a/plan/storage/memory/localid_test.go +++ b/plan/storage/memory/localid_test.go @@ -37,6 +37,23 @@ func TestLocalID(t *testing.T) { t.Errorf("exp nil, got %v", actErr) } + t.Log("retrieve known") + actLid, actErr := repo.FindOrNext("test") + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if actLid != 1 { + t.Errorf("exp 1, git %v", actLid) + } + t.Log("retrieve unknown") + actLid, actErr = repo.FindOrNext("new") + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if actLid != 2 { + t.Errorf("exp 2, got %v", actLid) + } + actIDs, actErr = repo.FindAll() if actErr != nil { t.Errorf("exp nil, got %v", actErr) diff --git a/plan/storage/memory/sync.go b/plan/storage/memory/sync.go new file mode 100644 index 0000000..42c6acb --- /dev/null +++ b/plan/storage/memory/sync.go @@ -0,0 +1,67 @@ +package memory + +import ( + "sort" + "sync" + "time" + + "go-mod.ewintr.nl/planner/item" +) + +type Sync struct { + items map[string]item.Item + mutex sync.RWMutex +} + +func NewSync() *Sync { + return &Sync{ + items: make(map[string]item.Item), + } +} + +func (r *Sync) FindAll() ([]item.Item, error) { + r.mutex.RLock() + defer r.mutex.RUnlock() + + items := make([]item.Item, 0, len(r.items)) + for _, item := range r.items { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + + return items, nil +} + +func (r *Sync) Store(e item.Item) error { + r.mutex.Lock() + defer r.mutex.Unlock() + + r.items[e.ID] = e + + return nil +} + +func (r *Sync) DeleteAll() error { + r.mutex.Lock() + defer r.mutex.Unlock() + + r.items = make(map[string]item.Item) + + return nil +} + +func (r *Sync) LastUpdate() (time.Time, error) { + r.mutex.RLock() + defer r.mutex.RUnlock() + + var last time.Time + for _, i := range r.items { + if i.Updated.After(last) { + last = i.Updated + } + } + + return last, nil +} diff --git a/plan/storage/memory/sync_test.go b/plan/storage/memory/sync_test.go new file mode 100644 index 0000000..94a7a89 --- /dev/null +++ b/plan/storage/memory/sync_test.go @@ -0,0 +1,59 @@ +package memory_test + +import ( + "fmt" + "testing" + "time" + + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/storage/memory" +) + +func TestSync(t *testing.T) { + t.Parallel() + + mem := memory.NewSync() + + t.Log("store") + now := time.Now() + ts := now + count := 3 + for i := 0; i < count; i++ { + mem.Store(item.Item{ + ID: fmt.Sprintf("id-%d", i), + Updated: ts, + }) + ts = ts.Add(-1 * time.Minute) + } + + t.Log("find all") + actItems, actErr := mem.FindAll() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if len(actItems) != count { + t.Errorf("exp %v, got %v", count, len(actItems)) + } + + t.Log("last update") + actLU, actErr := mem.LastUpdate() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if !actLU.Equal(now) { + t.Errorf("exp %v, got %v", now, actLU) + } + + t.Log("delete all") + if err := mem.DeleteAll(); err != nil { + t.Errorf("exp nil, got %v", err) + } + actItems, actErr = mem.FindAll() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if len(actItems) != 0 { + t.Errorf("exp 0, got %v", len(actItems)) + } + +} diff --git a/plan/storage/sqlite/localid.go b/plan/storage/sqlite/localid.go index 1e213c3..f97f1d0 100644 --- a/plan/storage/sqlite/localid.go +++ b/plan/storage/sqlite/localid.go @@ -33,6 +33,10 @@ FROM localids return result, nil } +func (l *LocalID) FindOrNext(id string) (int, error) { + return 0, nil +} + func (l *LocalID) Next() (int, error) { idMap, err := l.FindAll() if err != nil { diff --git a/plan/storage/sqlite/sqlite.go b/plan/storage/sqlite/sqlite.go index 90f84f3..f976280 100644 --- a/plan/storage/sqlite/sqlite.go +++ b/plan/storage/sqlite/sqlite.go @@ -18,6 +18,13 @@ var migrations = []string{ `PRAGMA synchronous=NORMAL`, `PRAGMA cache_size=2000`, `CREATE TABLE localids ("id" TEXT UNIQUE, "local_id" INTEGER)`, + `CREATE TABLE items ( + id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL, + updated TIMESTAMP NOT NULL, + deleted BOOLEAN NOT NULL, + body TEXT NOT NULL +)`, } var ( @@ -27,10 +34,10 @@ var ( ErrSqliteFailure = errors.New("sqlite returned an error") ) -func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, error) { +func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, *SqliteSync, error) { db, err := sql.Open("sqlite", dbPath) if err != nil { - return nil, nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) + return nil, nil, nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) } sl := &LocalID{ @@ -39,12 +46,15 @@ func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, error) { se := &SqliteEvent{ db: db, } - - if err := migrate(db, migrations); err != nil { - return nil, nil, err + ss := &SqliteSync{ + db: db, } - return sl, se, nil + if err := migrate(db, migrations); err != nil { + return nil, nil, nil, err + } + + return sl, se, ss, nil } func migrate(db *sql.DB, wanted []string) error { diff --git a/plan/storage/sqlite/sync.go b/plan/storage/sqlite/sync.go new file mode 100644 index 0000000..76669d6 --- /dev/null +++ b/plan/storage/sqlite/sync.go @@ -0,0 +1,92 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "time" + + "go-mod.ewintr.nl/planner/item" +) + +type SqliteSync struct { + db *sql.DB +} + +func NewSqliteSync(db *sql.DB) *SqliteSync { + return &SqliteSync{db: db} +} + +func (s *SqliteSync) FindAll() ([]item.Item, error) { + rows, err := s.db.Query("SELECT id, kind, updated, deleted, body FROM items") + if err != nil { + return nil, fmt.Errorf("%w: failed to query items: %v", ErrSqliteFailure, err) + } + defer rows.Close() + + var items []item.Item + for rows.Next() { + var i item.Item + var updatedStr string + err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &i.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to scan item: %v", ErrSqliteFailure, err) + } + i.Updated, err = time.Parse(time.RFC3339, updatedStr) + if err != nil { + return nil, fmt.Errorf("%w: failed to parse updated time: %v", ErrSqliteFailure, err) + } + items = append(items, i) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("%w: error iterating over rows: %v", ErrSqliteFailure, err) + } + + return items, nil +} + +func (s *SqliteSync) Store(i item.Item) error { + // Ensure we have a valid time + if i.Updated.IsZero() { + i.Updated = time.Now() + } + + _, err := s.db.Exec( + "INSERT OR REPLACE INTO items (id, kind, updated, deleted, body) VALUES (?, ?, ?, ?, ?)", + i.ID, + i.Kind, + i.Updated.UTC().Format(time.RFC3339), + i.Deleted, + sql.NullString{String: i.Body, Valid: i.Body != ""}, // This allows empty string but not NULL + ) + if err != nil { + return fmt.Errorf("%w: failed to store item: %v", ErrSqliteFailure, err) + } + return nil +} + +func (s *SqliteSync) DeleteAll() error { + _, err := s.db.Exec("DELETE FROM items") + if err != nil { + return fmt.Errorf("%w: failed to delete all items: %v", ErrSqliteFailure, err) + } + return nil +} + +func (s *SqliteSync) LastUpdate() (time.Time, error) { + var updatedStr sql.NullString + err := s.db.QueryRow("SELECT MAX(updated) FROM items").Scan(&updatedStr) + if err != nil { + return time.Time{}, fmt.Errorf("%w: failed to get last update: %v", ErrSqliteFailure, err) + } + + if !updatedStr.Valid { + return time.Time{}, nil // Return zero time if NULL or no rows + } + + lastUpdate, err := time.Parse(time.RFC3339, updatedStr.String) + if err != nil { + return time.Time{}, fmt.Errorf("%w: failed to parse last update time: %v", ErrSqliteFailure, err) + } + return lastUpdate, nil +} diff --git a/plan/storage/storage.go b/plan/storage/storage.go index e20c24b..9dc2794 100644 --- a/plan/storage/storage.go +++ b/plan/storage/storage.go @@ -3,6 +3,7 @@ package storage import ( "errors" "sort" + "time" "go-mod.ewintr.nl/planner/item" ) @@ -13,11 +14,19 @@ var ( type LocalID interface { FindAll() (map[string]int, error) + FindOrNext(id string) (int, error) Next() (int, error) Store(id string, localID int) error Delete(id string) error } +type Sync interface { + FindAll() ([]item.Item, error) + Store(i item.Item) error + DeleteAll() error + LastUpdate() (time.Time, error) +} + type Event interface { Store(event item.Event) error Find(id string) (item.Event, error) diff --git a/sync/client/client.go b/sync/client/client.go index 9b3a4bc..7085971 100644 --- a/sync/client/client.go +++ b/sync/client/client.go @@ -1,89 +1,12 @@ package client import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" "time" "go-mod.ewintr.nl/planner/item" ) -type Client struct { - baseURL string - apiKey string - c *http.Client -} - -func NewClient(url, apiKey string) *Client { - return &Client{ - baseURL: url, - apiKey: apiKey, - c: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -func (c *Client) Update(items []item.Item) error { - body, err := json.Marshal(items) - if err != nil { - return fmt.Errorf("could not marhal body: %v", err) - } - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/sync", c.baseURL), bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("could not create request: %v", err) - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) - - res, err := c.c.Do(req) - if err != nil { - return fmt.Errorf("could not make request: %v", err) - } - if res.StatusCode != http.StatusNoContent { - return fmt.Errorf("server returned status %d", res.StatusCode) - } - - return nil -} - -func (c *Client) Updated(ks []item.Kind, ts time.Time) ([]item.Item, error) { - ksStr := make([]string, 0, len(ks)) - for _, k := range ks { - ksStr = append(ksStr, string(k)) - } - u := fmt.Sprintf("%s/sync?ks=%s", c.baseURL, strings.Join(ksStr, ",")) - if !ts.IsZero() { - u = fmt.Sprintf("%s&ts=", url.QueryEscape(ts.Format(time.RFC3339))) - } - req, err := http.NewRequest(http.MethodGet, u, nil) - if err != nil { - return nil, fmt.Errorf("could not create request: %v", err) - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) - - res, err := c.c.Do(req) - if err != nil { - return nil, fmt.Errorf("could not get response: %v", err) - } - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("server returned status %d", res.StatusCode) - } - - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("could not read response body: %v", err) - } - - var items []item.Item - if err := json.Unmarshal(body, &items); err != nil { - return nil, fmt.Errorf("could not unmarshal response body: %v", err) - } - - return items, nil +type Client interface { + Update(items []item.Item) error + Updated(ks []item.Kind, ts time.Time) ([]item.Item, error) } diff --git a/sync/client/http.go b/sync/client/http.go new file mode 100644 index 0000000..a94aeba --- /dev/null +++ b/sync/client/http.go @@ -0,0 +1,89 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "go-mod.ewintr.nl/planner/item" +) + +type HTTP struct { + baseURL string + apiKey string + c *http.Client +} + +func New(url, apiKey string) *HTTP { + return &HTTP{ + baseURL: url, + apiKey: apiKey, + c: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *HTTP) Update(items []item.Item) error { + body, err := json.Marshal(items) + if err != nil { + return fmt.Errorf("could not marhal body: %v", err) + } + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/sync", c.baseURL), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("could not create request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) + + res, err := c.c.Do(req) + if err != nil { + return fmt.Errorf("could not make request: %v", err) + } + if res.StatusCode != http.StatusNoContent { + return fmt.Errorf("server returned status %d", res.StatusCode) + } + + return nil +} + +func (c *HTTP) Updated(ks []item.Kind, ts time.Time) ([]item.Item, error) { + ksStr := make([]string, 0, len(ks)) + for _, k := range ks { + ksStr = append(ksStr, string(k)) + } + u := fmt.Sprintf("%s/sync?ks=%s", c.baseURL, strings.Join(ksStr, ",")) + if !ts.IsZero() { + u = fmt.Sprintf("%s&ts=", url.QueryEscape(ts.Format(time.RFC3339))) + } + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("could not create request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) + + res, err := c.c.Do(req) + if err != nil { + return nil, fmt.Errorf("could not get response: %v", err) + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d", res.StatusCode) + } + + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("could not read response body: %v", err) + } + + var items []item.Item + if err := json.Unmarshal(body, &items); err != nil { + return nil, fmt.Errorf("could not unmarshal response body: %v", err) + } + + return items, nil +} diff --git a/sync/client/memory.go b/sync/client/memory.go new file mode 100644 index 0000000..1b84917 --- /dev/null +++ b/sync/client/memory.go @@ -0,0 +1,45 @@ +package client + +import ( + "slices" + "sync" + "time" + + "go-mod.ewintr.nl/planner/item" +) + +type Memory struct { + items map[string]item.Item + sync.RWMutex +} + +func NewMemory() *Memory { + return &Memory{ + items: make(map[string]item.Item, 0), + } +} + +func (m *Memory) Update(items []item.Item) error { + m.Lock() + defer m.Unlock() + + for _, i := range items { + m.items[i.ID] = i + } + + return nil +} + +func (m *Memory) Updated(kw []item.Kind, ts time.Time) ([]item.Item, error) { + m.RLock() + defer m.RUnlock() + + res := make([]item.Item, 0) + for _, i := range m.items { + if slices.Contains(kw, i.Kind) && (i.Updated.After(ts) || i.Updated.Equal(ts)) { + res = append(res, i) + } + } + + return res, nil +} diff --git a/sync/client/memory_test.go b/sync/client/memory_test.go new file mode 100644 index 0000000..c1f451c --- /dev/null +++ b/sync/client/memory_test.go @@ -0,0 +1,60 @@ +package client_test + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/sync/client" +) + +func TestMemory(t *testing.T) { + t.Parallel() + + mem := client.NewMemory() + + now := time.Now() + items := []item.Item{ + {ID: "a", Kind: item.KindTask, Updated: now.Add(-15 * time.Minute)}, + {ID: "b", Kind: item.KindEvent, Updated: now.Add(-10 * time.Minute)}, + {ID: "c", Kind: item.KindTask, Updated: now.Add(-5 * time.Minute)}, + } + if err := mem.Update(items); err != nil { + t.Errorf("exp nil, got %v", err) + } + + for _, tc := range []struct { + name string + ks []item.Kind + ts time.Time + expItems []item.Item + }{ + { + name: "empty", + ks: make([]item.Kind, 0), + expItems: make([]item.Item, 0), + }, + { + name: "kind", + ks: []item.Kind{item.KindEvent}, + expItems: []item.Item{items[1]}, + }, + { + name: "timestamp", + ks: []item.Kind{item.KindTask, item.KindEvent}, + ts: now.Add(-10 * time.Minute), + expItems: items[1:], + }, + } { + t.Run(tc.name, func(t *testing.T) { + actItems, actErr := mem.Updated(tc.ks, tc.ts) + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if diff := cmp.Diff(tc.expItems, actItems); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) + } + }) + } +}