Compare commits

...

15 Commits
main ... sync

Author SHA1 Message Date
Erik Winter 293e7cc911 wip 2024-10-22 07:26:59 +02:00
Erik Winter 801947ab30 wip 2024-10-18 07:26:11 +02:00
Erik Winter 51775ae613 start sync test 2024-10-17 07:30:02 +02:00
Erik Winter 895cdc5864 start sync test 2024-10-17 07:29:53 +02:00
Erik Winter 65e8a972e4 last update 2024-10-16 07:34:08 +02:00
Erik Winter 758f3ccd43 findornext 2024-10-16 07:20:08 +02:00
Erik Winter 405cf05341 wip 2024-10-15 07:28:46 +02:00
Erik Winter 86c4cdc957 wip 2024-10-11 07:24:38 +02:00
Erik Winter ef7beacec8 memory client 2024-10-10 07:24:49 +02:00
Erik Winter 6fe7fe009e wip 2024-10-09 07:26:39 +02:00
Erik Winter 5ab5505d2b mem test 2024-10-09 07:15:17 +02:00
Erik Winter 4e37009599 wip 2024-10-08 07:22:06 +02:00
Erik Winter c3d8a3e0fe wip 2024-10-08 07:21:27 +02:00
Erik Winter 4c69f00abd wip 2024-10-07 11:32:53 +02:00
Erik Winter 0f903a5645 sync 2024-10-07 11:11:18 +02:00
15 changed files with 597 additions and 122 deletions

86
plan/command/sync.go Normal file
View File

@ -0,0 +1,86 @@
package command
import (
"encoding/json"
"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", err)
}
recItems, err := client.Updated([]item.Kind{item.KindEvent}, ts)
if err != nil {
return fmt.Errorf("could not receive updates: %v", err)
}
// import to local
lidMap, err := localIDRepo.FindAll()
if err != nil {
return fmt.Errorf("could not get local ids: %v", err)
}
for _, ri := range recItems {
var eBody item.EventBody
if err := json.Unmarshal([]byte(ri.Body), &eBody); err != nil {
return fmt.Errorf("could not unmarshal event body: %v", err)
}
e := item.Event{
ID: ri.ID,
EventBody: eBody,
}
if err := eventRepo.Store(e); err != nil {
return fmt.Errorf("could not store event: %v", err)
}
lid, ok := lidMap[ri.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(ri.ID, lid); err != nil {
return fmt.Errorf("could not store local id: %v", err)
}
}
return nil
}

91
plan/command/sync_test.go Normal file
View File

@ -0,0 +1,91 @@
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()
syncClient := client.NewMemory()
syncRepo := memory.NewSync()
localIDRepo := memory.NewLocalID()
eventRepo := memory.NewEvent()
for _, tc := range []struct {
name string
present []item.Event
updated []item.Item
exp []item.Event
}{
{},
} {
t.Run(tc.name, func(t *testing.T) {
})
}
}

View File

@ -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,12 +24,14 @@ 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",
@ -37,6 +40,7 @@ func main() {
command.NewListCmd(localIDRepo, eventRepo),
command.NewUpdateCmd(localIDRepo, eventRepo),
command.NewDeleteCmd(localIDRepo, eventRepo),
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) {

View File

@ -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()

View File

@ -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)

View File

@ -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
}

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -27,10 +27,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 +39,13 @@ func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, error) {
se := &SqliteEvent{
db: db,
}
ss := &SqliteSync{}
if err := migrate(db, migrations); err != nil {
return nil, nil, err
return nil, nil, nil, err
}
return sl, se, nil
return sl, se, ss, nil
}
func migrate(db *sql.DB, wanted []string) error {

View File

@ -0,0 +1,29 @@
package sqlite
import (
"time"
"go-mod.ewintr.nl/planner/item"
)
type SqliteSync struct{}
func NewSqliteSync() *SqliteSync {
return &SqliteSync{}
}
func (s *SqliteSync) FindAll() ([]item.Item, error) {
return nil, nil
}
func (s *SqliteSync) Store(i item.Item) error {
return nil
}
func (s *SqliteSync) DeleteAll() error {
return nil
}
func (s *SqliteSync) LastUpdate() (time.Time, error) {
return time.Time{}, nil
}

View File

@ -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)

View File

@ -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)
}

89
sync/client/http.go Normal file
View File

@ -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
}

45
sync/client/memory.go Normal file
View File

@ -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
}

View File

@ -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)
}
})
}
}