diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 83b1d84..fa1fcc8 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -27,7 +27,7 @@ func RegisterCallbacks(api *api.Api) { }), "cloneCalendar": js.FuncOf(func(this js.Value, args []js.Value) any { return wrapPromise(func() (any, error) { - return nil, api.CloneCalendar(args[0].String(), args[1].String()) + return nil, api.CloneCalendar(args[0].String(), args[1].String(), args[2].Bool()) }) }), "removeCalendar": js.FuncOf(func(this js.Value, args []js.Value) any { @@ -52,7 +52,7 @@ func RegisterCallbacks(api *api.Api) { }), "updateRemote": js.FuncOf(func(this js.Value, args []js.Value) any { return wrapPromise(func() (any, error) { - return nil, api.UpdateRemote(args[0].String(), args[1].String()) + return nil, api.UpdateRemote(args[0].String(), args[1].String(), args[2].Bool()) }) }), "createEvent": js.FuncOf(func(this js.Value, args []js.Value) any { @@ -90,6 +90,21 @@ func RegisterCallbacks(api *api.Api) { return api.GetEvents(args[0].String(), args[1].String()) }) }), + "createTag": js.FuncOf(func(this js.Value, args []js.Value) any { + return wrapPromise(func() (any, error) { + return api.CreateTag(args[0].String(), args[1].String()) + }) + }), + "updateTag": js.FuncOf(func(this js.Value, args []js.Value) any { + return wrapPromise(func() (any, error) { + return api.UpdateTag(args[0].String(), args[1].String()) + }) + }), + "removeTag": js.FuncOf(func(this js.Value, args []js.Value) any { + return wrapPromise(func() (any, error) { + return nil, api.RemoveTag(args[0].String(), args[1].String()) + }) + }), "setCorsProxy": js.FuncOf(func(this js.Value, args []js.Value) any { return wrapPromise(func() (any, error) { return nil, api.SetCorsProxy(args[0].String()) diff --git a/e2e/calendars_test.go b/e2e/calendars_test.go index b5986de..5bde55f 100644 --- a/e2e/calendars_test.go +++ b/e2e/calendars_test.go @@ -11,6 +11,8 @@ import ( "github.com/git-calendar/core/pkg/filesystem" ) +const TestCalendarName = "test" + func TestCreateCalendar(t *testing.T) { c := core.NewCore() @@ -98,7 +100,7 @@ func TestListCalendars_WithRemote(t *testing.T) { remoteUrl := "https://github.com/git-calendar/calendar.git" - err = c.UpdateRemote(TestCalendarName, mustParseUrl(remoteUrl)) + err = c.UpdateRemote(TestCalendarName, mustParseUrl(remoteUrl), false) if err != nil { t.Fatalf("failed to update remotes: %v", err) } diff --git a/e2e/events_repeating_test.go b/e2e/events_repeating_test.go index fb235c9..c3c6956 100644 --- a/e2e/events_repeating_test.go +++ b/e2e/events_repeating_test.go @@ -9,8 +9,6 @@ import ( "github.com/google/uuid" ) -const TestCalendarName = "test" - func TestRepeatingEvent_GetEvents_UntilWeekly_GeneratesOccurrencesInRange(t *testing.T) { c := newTestCore(t) @@ -150,7 +148,7 @@ func TestRepeatingEvent_Update_Current_DetachesOnlyTargetChild(t *testing.T) { if detached.Repeat != nil { t.Fatalf("detached event should not repeat") } - if detached.ParentId != uuid.Nil { + if detached.ParentId != nil { t.Fatalf("detached event should not have parent id, got %s", detached.ParentId) } if detached.Title != updated.Title { @@ -226,7 +224,7 @@ func TestRepeatingEvent_Update_Following_SplitsSeriesFromTargetChild(t *testing. t.Fatalf("failed to update child event with Following strategy: %v", err) } - if newParent.ParentId != uuid.Nil { + if newParent.ParentId != nil { t.Fatalf("new event should be a parent, got ParentId %s", newParent.ParentId) } if newParent.Title != updated.Title { @@ -282,7 +280,7 @@ func TestRepeatingEvent_Update_Following_SecondChild_DoesNotLeaveInvalidOldParen if err != nil { t.Fatalf("failed to update second child with Following strategy: %v", err) } - if newParent.ParentId != uuid.Nil { + if newParent.ParentId != nil { t.Fatalf("new event should be a parent, got ParentId %s", newParent.ParentId) } diff --git a/e2e/git_test.go b/e2e/git_test.go index 93d1f29..8894005 100644 --- a/e2e/git_test.go +++ b/e2e/git_test.go @@ -21,12 +21,12 @@ func TestUpdateRemote_ReplacesExistingRemote(t *testing.T) { oldCalRemote := "https://github.com/git-calendar/old-calendar.git" newCalRemote := "https://github.com/git-calendar/new-calendar.git" - err = c.UpdateRemote(TestCalendarName, mustParseUrl(oldCalRemote)) + err = c.UpdateRemote(TestCalendarName, mustParseUrl(oldCalRemote), false) if err != nil { t.Fatalf("failed to set initial remotes: %v", err) } - err = c.UpdateRemote(TestCalendarName, mustParseUrl(newCalRemote)) + err = c.UpdateRemote(TestCalendarName, mustParseUrl(newCalRemote), false) if err != nil { t.Fatalf("failed to replace remotes: %v", err) } @@ -69,12 +69,12 @@ func TestUpdateRemote_DeletesWhenEmpty(t *testing.T) { _ = c.RemoveCalendar(TestCalendarName) }) - err = c.UpdateRemote(TestCalendarName, mustParseUrl("https://github.com/git-calendar/calendar.git")) + err = c.UpdateRemote(TestCalendarName, mustParseUrl("https://github.com/git-calendar/calendar.git"), false) if err != nil { t.Fatalf("failed to set remotes: %v", err) } - err = c.UpdateRemote(TestCalendarName, nil) + err = c.UpdateRemote(TestCalendarName, nil, false) if err != nil { t.Fatalf("failed to clear remotes: %v", err) } @@ -113,7 +113,7 @@ func TestUpdateRemote_MissingCalendar(t *testing.T) { t.Fatal(err) } - err = c.UpdateRemote(TestCalendarName, mustParseUrl("https://github.com/git-calendar/calendar.git")) + err = c.UpdateRemote(TestCalendarName, mustParseUrl("https://github.com/git-calendar/calendar.git"), false) if err == nil { t.Fatal("expected error, got nil") } diff --git a/e2e/tags_test.go b/e2e/tags_test.go new file mode 100644 index 0000000..2f02963 --- /dev/null +++ b/e2e/tags_test.go @@ -0,0 +1,204 @@ +package e2e + +import ( + "testing" + + "github.com/git-calendar/core/pkg/core" + "github.com/google/uuid" +) + +const TestTagName = "test" + +func TestTag_CreateTag_CreatesTag(t *testing.T) { + c := newTestCore(t) + + tag := newTestTag("work", "blue") + + created, err := c.CreateTag(TestTagName, tag) + if err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + if created == nil { + t.Fatalf("expected created tag") + } + + assertTagEqual(t, tag, *created) +} + +func TestTag_CreateTag_DuplicateReturnsError(t *testing.T) { + c := newTestCore(t) + + tag := newTestTag("work", "blue") + + if _, err := c.CreateTag(TestTagName, tag); err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + created, err := c.CreateTag(TestTagName, tag) + if err == nil { + t.Fatalf("expected duplicate tag error") + } + if created != nil { + t.Fatalf("expected nil created tag, got %+v", created) + } +} + +func TestTag_CreateTag_InvalidTagReturnsError(t *testing.T) { + c := newTestCore(t) + + tag := core.Tag{ + Name: "", + Color: "blue", + } + + created, err := c.CreateTag(TestTagName, tag) + if err == nil { + t.Fatalf("expected invalid tag error") + } + if created != nil { + t.Fatalf("expected nil created tag, got %+v", created) + } +} + +func TestTag_UpdateTag_UpdatesExistingTag(t *testing.T) { + c := newTestCore(t) + + tag := newTestTag("work", "blue") + + if _, err := c.CreateTag(TestTagName, tag); err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + updated := core.Tag{ + Id: tag.Id, + Name: "personal", + Color: "blue", + } + + got, err := c.UpdateTag(TestTagName, updated) + if err != nil { + t.Fatalf("UpdateTag failed: %v", err) + } + if got == nil { + t.Fatalf("expected updated tag") + } + + assertTagEqual(t, updated, *got) +} + +func TestTag_UpdateTag_InvalidTagReturnsError(t *testing.T) { + c := newTestCore(t) + + tag := core.Tag{ + Name: "work", + Color: "red", + } + + updated, err := c.UpdateTag(TestTagName, tag) + if err == nil { + t.Fatalf("expected invalid tag error") + } + if updated != nil { + t.Fatalf("expected nil updated tag, got %+v", updated) + } +} + +func TestTag_UpdateTag_MissingTagReturnsErrorInsteadOfPanicking(t *testing.T) { + c := newTestCore(t) + + tag := newTestTag("missing", "blue") + + defer func() { + if r := recover(); r != nil { + t.Fatalf("expected error for missing tag, got panic: %v", r) + } + }() + + updated, err := c.UpdateTag(TestTagName, tag) + if err == nil { + t.Fatalf("expected missing tag error") + } + if updated != nil { + t.Fatalf("expected nil updated tag, got %+v", updated) + } +} + +func TestTag_RemoveTag_RemovesTag(t *testing.T) { + c := newTestCore(t) + + tag := newTestTag("work", "blue") + + if _, err := c.CreateTag(TestTagName, tag); err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + if err := c.RemoveTag(TestTagName, tag.Id); err != nil { + t.Fatalf("RemoveTag failed: %v", err) + } + + // creating the same id again proves it was removed from the in-memory tag list + recreated := core.Tag{ + Id: tag.Id, + Name: "recreated", + Color: "blue", + } + + got, err := c.CreateTag(TestTagName, recreated) + if err != nil { + t.Fatalf("CreateTag after RemoveTag failed: %v", err) + } + if got == nil { + t.Fatalf("expected recreated tag") + } + + assertTagEqual(t, recreated, *got) +} + +func TestTag_RemoveTag_InvalidIdReturnsError(t *testing.T) { + c := newTestCore(t) + + err := c.RemoveTag(TestTagName, uuid.Nil) + if err == nil { + t.Fatalf("expected invalid tag id error") + } +} + +func TestTag_InvalidCalendarReturnsError(t *testing.T) { + c := newTestCore(t) + + tag := newTestTag("work", "green") + + if _, err := c.CreateTag("missing", tag); err == nil { + t.Fatalf("expected CreateTag invalid calendar error") + } + + if _, err := c.UpdateTag("missing", tag); err == nil { + t.Fatalf("expected UpdateTag invalid calendar error") + } + + if err := c.RemoveTag("missing", tag.Id); err == nil { + t.Fatalf("expected RemoveTag invalid calendar error") + } +} + +func newTestTag(name string, color string) core.Tag { + return core.Tag{ + Id: uuid.New(), + Name: name, + Color: color, + } +} + +func assertTagEqual(t *testing.T, want core.Tag, got core.Tag) { + t.Helper() + + if got.Id != want.Id { + t.Fatalf("tag id mismatch: expected %s, got %s", want.Id, got.Id) + } + if got.Name != want.Name { + t.Fatalf("tag name mismatch: expected %q, got %q", want.Name, got.Name) + } + if got.Color != want.Color { + t.Fatalf("tag color mismatch: expected %q, got %q", want.Color, got.Color) + } +} diff --git a/notes.md b/notes.md index 067283a..331fcd6 100644 --- a/notes.md +++ b/notes.md @@ -16,7 +16,7 @@ - [x] connect repeating event exceptions - exception needs to have uuid - time encoded inside uuidv8 - - [ ] config file per repo + - [ ] config file per repo? - tags - [ ] better tests - [x] load repositories @@ -29,7 +29,7 @@ - github-pages? - custom http file server? - [x] encryption - - [x] storing a key (in opfs?) + - [x] storing a key - values-only - deterministic? (same input <=> same output) - +good git diffs @@ -44,25 +44,30 @@ ``` .git-calendar-data/ -├── main/ +├── default/ │ ├── .git/ │ ├── events/ │ │ └── .json +│ ├── tags/ +│ │ └── .json │ ├── index.jsonl │ └── index-rich.jsonl ├── shared/ │ ├── .git/ │ ├── events/ │ │ └── .json +│ ├── tags/ +│ │ └── .json │ ├── index.jsonl │ └── index-rich.jsonl -├── main.key +├── default.key +├── shared.readonly └── shared.key ``` ### Testing Web/Wasm parts: -(there are no wasm only test at this time, but could be useful) +(there are no wasm-only tests at this time, but could be useful) Install [wasmbrowsertest](https://github.com/agnivade/wasmbrowsertest): ```sh diff --git a/pkg/api/api.go b/pkg/api/api.go index 5e8a361..1890177 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -43,29 +43,27 @@ func (a *Api) RemoveCalendar(name string) error { return a.inner.RemoveCalendar( func (a *Api) RenameCalendar(oldName, newName string) error { return a.inner.RenameCalendar(oldName, newName) } - -func (a *Api) LoadCalendars() error { return a.inner.LoadCalendars() } - +func (a *Api) LoadCalendars() error { return a.inner.LoadCalendars() } func (a *Api) SetCorsProxy(proxyUrl string) error { return a.inner.SetCorsProxy(proxyUrl) } func (a *Api) SyncAll() error { return a.inner.SyncAll() } func (a *Api) ExportZip(calendar string) ([]byte, error) { return a.inner.ExportZip(calendar) } // ------------------------------ Wrapper methods encoding and decoding JSONs ------------------------------ -func (a *Api) UpdateRemote(calendar string, remoteUrl string) error { +func (a *Api) UpdateRemote(calendar string, remoteUrl string, readonly bool) error { parsed, err := url.Parse(remoteUrl) if err != nil { return fmt.Errorf("remoteUrl is invalid: %w", err) } - return a.inner.UpdateRemote(calendar, parsed) + return a.inner.UpdateRemote(calendar, parsed, readonly) } -func (a *Api) CloneCalendar(repoUrl, password string) error { +func (a *Api) CloneCalendar(repoUrl, password string, readonly bool) error { parsedUrl, err := url.Parse(repoUrl) if err != nil { return fmt.Errorf("repoUrl is invalid: %w", err) } - return a.inner.CloneCalendar(parsedUrl, password) + return a.inner.CloneCalendar(parsedUrl, password, readonly) } func (a *Api) ListCalendars() (string, error) { @@ -174,6 +172,59 @@ func (a *Api) GetEvents(from, to string) (string, error) { return string(jsonBytes), nil } +func (a *Api) CreateTag(calendar, tagJson string) (string, error) { + var tag core.Tag + err := json.Unmarshal([]byte(tagJson), &tag) + if err != nil { + fmt.Println("CalendarCore got: ", tagJson) + return emptyJson, fmt.Errorf("failed to unmarshal tag data: %w", err) + } + + newTag, err := a.inner.CreateTag(calendar, tag) + if err != nil { + fmt.Println("CalendarCore got: ", tagJson) + return emptyJson, err + } + + jsonBytes, err := json.Marshal(newTag) + if err != nil { + return emptyJson, err + } + + return string(jsonBytes), nil +} + +func (a *Api) UpdateTag(calendar, tagJson string) (string, error) { + var tag core.Tag + err := json.Unmarshal([]byte(tagJson), &tag) + if err != nil { + fmt.Println("CalendarCore got: ", tagJson) + return emptyJson, fmt.Errorf("failed to unmarshal tag data: %w", err) + } + + newTag, err := a.inner.UpdateTag(calendar, tag) + if err != nil { + fmt.Println("CalendarCore got: ", tagJson) + return emptyJson, err + } + + jsonBytes, err := json.Marshal(newTag) + if err != nil { + return emptyJson, err + } + + return string(jsonBytes), nil +} + +func (a *Api) RemoveTag(calendar, id string) error { + parsedId, err := uuid.Parse(id) + if err != nil { + return fmt.Errorf("invalid tag id: %w", err) + } + + return a.inner.RemoveTag(calendar, parsedId) +} + // ------------------------------------------------ Helpers ------------------------------------------------- // A helper which: diff --git a/pkg/api/event.go b/pkg/api/models.go similarity index 65% rename from pkg/api/event.go rename to pkg/api/models.go index 6afd3b1..ae71bc7 100644 --- a/pkg/api/event.go +++ b/pkg/api/models.go @@ -11,7 +11,7 @@ type Event struct { From string // RFC3339 format e.g., 2009-11-10T23:00:00Z (the default format of json.Marshal() for time.Time) To string // RFC3339 format e.g., 2009-11-10T23:00:00Z (the default format of json.Marshal() for time.Time) Calendar string - Tag string + TagId string ParentId string Repeat *Repetition } @@ -23,3 +23,20 @@ type Repetition struct { Count int Exceptions []string } + +// A DTO for Kotlin/Swift to use as the calendar structure. +// +// This calendar isn't used in Go itself, but serves as a "shape definition" for `gomobile` to bind it into Kotlin/Swift. +type Calendar struct { + Name string + Tags []Tag + RemoteUrl string + Encrypted bool + Readonly bool +} + +type Tag struct { + Id string + Name string + Color string +} diff --git a/pkg/core/calendar.go b/pkg/core/calendar.go index 91e51c2..04beedb 100644 --- a/pkg/core/calendar.go +++ b/pkg/core/calendar.go @@ -4,7 +4,9 @@ import ( "encoding/json" "errors" "fmt" - "slices" + "os" + "path" + "strings" gogit "github.com/go-git/go-git/v5" ) @@ -18,17 +20,17 @@ type Calendar struct { repository *gogit.Repository } -type Tag struct { - Name string `json:"name,omitzero"` - Color string `json:"color,omitzero"` -} - func (cal *Calendar) Validate() error { if cal == nil { return nil } if cal.Name == "" { - return errors.New("name cannot be empty") + return errors.New("calendar name cannot be empty") + } + for _, t := range cal.Tags { + if err := t.Validate(); err != nil { + return err + } } return nil } @@ -62,8 +64,8 @@ func (cal *Calendar) RemoteURL() (string, error) { func (cal *Calendar) MarshalJSON() ([]byte, error) { type calendarJSON struct { Name string `json:"name"` - Tags []Tag `json:"tags,omitempty"` - RemoteURL string `json:"remote_url,omitempty"` + Tags []Tag `json:"tags"` + RemoteURL string `json:"remote_url"` Encrypted bool `json:"encrypted"` Readonly bool `json:"readonly"` } @@ -75,9 +77,59 @@ func (cal *Calendar) MarshalJSON() ([]byte, error) { return json.Marshal(calendarJSON{ Name: cal.Name, - Tags: slices.Clone(cal.Tags), + Tags: cal.Tags, RemoteURL: remoteURL, Encrypted: cal.IsEncrypted(), Readonly: cal.Readonly, }) } + +func (cal *Calendar) LoadTags() error { + wt, err := cal.repository.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + tags, err := readTagFiles(wt, cal.EncryptionKey) + if err != nil { + return fmt.Errorf("failed to load tags: %w", err) + } + + cal.Tags = tags + + return nil +} + +func readTagFiles(wt *gogit.Worktree, key []byte) ([]Tag, error) { + entries, err := wt.Filesystem.ReadDir(TagsDirName) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read tags dir %q: %w", TagsDirName, err) + } + + var tags []Tag + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + gitPath := path.Join(TagsDirName, entry.Name()) + file, err := wt.Filesystem.Open(gitPath) + if err != nil { + return nil, fmt.Errorf("failed to open tag %q: %w", gitPath, err) + } + defer file.Close() + + var tag Tag + if err := tag.LoadFromFile(file, key); err != nil { + return nil, fmt.Errorf("failed to load tag %q: %w", gitPath, err) + } + + tags = append(tags, tag) + } + + return tags, nil +} diff --git a/pkg/core/constants.go b/pkg/core/constants.go index 1a00ff1..4c90680 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -1,10 +1,11 @@ package core const ( - IndexFileName string = "index.json" - RichIndexFileName string = "index-rich.json" + // IndexFileName string = "index.json" + // RichIndexFileName string = "index-rich.json" EventsDirName string = "events" + TagsDirName = "tags" GitAuthorName string = "git-calendar" GitRemoteName string = "origin" diff --git a/pkg/core/core.go b/pkg/core/core.go index f796704..3a18c2b 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -17,6 +17,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/cache" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" gogitfs "github.com/go-git/go-git/v5/storage/filesystem" "github.com/google/uuid" ) @@ -28,8 +29,8 @@ type Core struct { intervalTree *IntervalTree events map[uuid.UUID]*Event calendars map[string]*Calendar - fs billy.Filesystem // root "/" for OPFS, "$HOME" for classic FS - proxyUrl *url.URL // cors proxy, that works with "url" query param (like https://cors-proxy.abc/?url=https://github.com/...) (only needed for the browser!) + fs billy.Filesystem // root "/" for OPFS/IDB, "$HOME" for classic FS + proxyUrl *url.URL // cors proxy, that works with "url" query param (like https://cors-proxy.abc/https://github.com/...) (only needed for the browser!) } // A "constructor" for Core. @@ -71,12 +72,12 @@ func (c *Core) SyncAll() error { } wg.Add(1) - go func() { + go func(cal *Calendar) { + defer wg.Done() if err := c.syncCalendar(cal); err != nil { errs <- fmt.Errorf("%q: sync failed: %w", cal.Name, err) } - wg.Done() - }() + }(cal) } wg.Wait() // wait for all calendar syncs to finish @@ -97,10 +98,25 @@ func (c *Core) SyncAll() error { // syncCalendar assumes the worktree is clean and all local calendar changes have already been committed. func (c *Core) syncCalendar(cal *Calendar) error { if err := fetchCalendar(cal, c.proxyUrl); err != nil { - if errors.Is(err, gogit.ErrRemoteNotFound) { + switch { + case errors.Is(err, gogit.ErrRemoteNotFound): return nil // this is ok + + case errors.Is(err, transport.ErrEmptyRemoteRepository): + // remote exists, but has no commits yet + // if we have local commits -> initialize by pushing + localCommit, err := gitmerge.GetLocalCommit(cal.repository, GitBranchName) + if err != nil { + return err + } + if localCommit == nil { + return nil // local and remote are both empty + } + return pushCalendar(cal, c.proxyUrl) + + default: + return fmt.Errorf("fetch: %w", err) } - return err } localCommit, remoteCommit, err := gitmerge.GetCommits(cal.repository, GitBranchName, GitRemoteName) @@ -109,6 +125,17 @@ func (c *Core) syncCalendar(cal *Calendar) error { } switch { + case localCommit == nil && remoteCommit == nil: + return nil + + case localCommit == nil: + // local has no commits + return fastForwardCalendar(cal, remoteCommit.Hash) + + case remoteCommit == nil: + // remote has no commits + return pushCalendar(cal, c.proxyUrl) + case localCommit.Hash == remoteCommit.Hash: return nil // already in sync diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index 97ac6e6..789af58 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -6,6 +6,7 @@ import ( "io" "maps" "net/url" + "os" "slices" "strings" @@ -14,6 +15,7 @@ import ( gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing/cache" + "github.com/go-git/go-git/v5/plumbing/transport" gogitfs "github.com/go-git/go-git/v5/storage/filesystem" ) @@ -28,9 +30,11 @@ func (c *Core) CreateCalendar(name, password string) error { return fmt.Errorf("failed to init calendar repo: %w", err) } - key, err := c.createKeyFile(name, password) - if err != nil { - return err + var key []byte = nil + if len(password) != 0 { + if key, err = c.createKeyFile(name, password); err != nil { + return err + } } cal := Calendar{ @@ -38,10 +42,10 @@ func (c *Core) CreateCalendar(name, password string) error { Tags: []Tag{}, EncryptionKey: key, repository: repo, + Readonly: false, } if err := cal.Validate(); err != nil { - _ = gogitutil.RemoveAll(c.fs, name) // cleanup - _ = c.fs.Remove(fmt.Sprintf("%s.key", name)) + c.RemoveCalendar(name) // cleanup return fmt.Errorf("calendar invalid: %w", err) } @@ -60,11 +64,16 @@ func (c *Core) ListCalendars() ([]Calendar, error) { result := make([]Calendar, 0, len(calendars)) for _, cal := range calendars { + slices.SortFunc(cal.Tags, func(a, b Tag) int { + return strings.Compare(a.Name, b.Name) + }) + result = append(result, Calendar{ Name: cal.Name, Tags: slices.Clone(cal.Tags), EncryptionKey: slices.Clone(cal.EncryptionKey), repository: cal.repository, + Readonly: cal.Readonly, }) } @@ -93,21 +102,29 @@ func (c *Core) LoadCalendars() error { continue } + // load key file var key []byte = nil keyFile, err := c.fs.Open(fmt.Sprintf("%s.key", name)) if err == nil { if key, err = io.ReadAll(keyFile); err != nil { - fmt.Printf("failed to read encryption key for %q repository: %v\n", name, err) + fmt.Printf("failed to read encryption key for %q calendar: %v\n", name, err) } keyFile.Close() } - c.calendars[name] = &Calendar{ + cal := &Calendar{ Name: name, - Tags: nil, // TODO: load tags EncryptionKey: key, repository: repo, + Readonly: c.isCalendarReadonly(name), } + + // load tags + if err := cal.LoadTags(); err != nil { + fmt.Printf("WARN: failed to load tags for %q calendar: %v\n", cal.Name, err) + } + + c.calendars[name] = cal } // load tree + events @@ -117,7 +134,7 @@ func (c *Core) LoadCalendars() error { eventsDir, _ := wt.Filesystem.Chroot(EventsDirName) eventEntries, _ := eventsDir.ReadDir("/") for _, eventEntry := range eventEntries { - if eventEntry.IsDir() { + if eventEntry.IsDir() || !strings.HasSuffix(eventEntry.Name(), ".json") { continue } @@ -155,7 +172,7 @@ func (c *Core) LoadCalendars() error { } // Clones a repository/calendar from url, using CORS proxy, if specified. -func (c *Core) CloneCalendar(repoUrl *url.URL, password string) error { +func (c *Core) CloneCalendar(repoUrl *url.URL, password string, readonly bool) error { calendarName := calendarNameFromUrl(repoUrl) if cal, ok := c.calendars[calendarName]; ok || cal != nil { return errors.New("calendar with this name already exists") @@ -182,31 +199,49 @@ func (c *Core) CloneCalendar(repoUrl *url.URL, password string) error { storage := gogitfs.NewStorage(dotGitFS, cache.NewObjectLRUDefault()) finalUrl, auth := prepareRepoUrl(repoUrl, c.proxyUrl) // clone now - newRepo, err := gogit.Clone(storage, repoFS, &gogit.CloneOptions{ + repo, err := gogit.Clone(storage, repoFS, &gogit.CloneOptions{ RemoteName: GitRemoteName, URL: finalUrl.String(), Auth: auth, }) if err != nil { c.RemoveCalendar(calendarName) // even on error, clone might create a directory, so let's delete it + if errors.Is(err, transport.ErrEmptyRemoteRepository) { + return fmt.Errorf("git clone failed: %w: maybe you wanted to init instead?", err) + } return fmt.Errorf("git clone failed: %w", err) } - key, err := c.createKeyFile(calendarName, password) - if err != nil { - return err + var key []byte = nil + if len(password) != 0 { + if key, err = c.createKeyFile(calendarName, password); err != nil { + return err + } } - c.calendars[calendarName] = &Calendar{ + cal := &Calendar{ Name: calendarName, - Tags: nil, // TODO: load tags EncryptionKey: key, - repository: newRepo, + repository: repo, + Readonly: readonly, + } + + // load tags + if err := cal.LoadTags(); err != nil { + fmt.Printf("WARN: failed to load tags for %q calendar: %v\n", cal.Name, err) } - // repair the remote url (set the pure url with auth, without proxy) - if err := c.UpdateRemote(calendarName, repoUrl); err != nil { - return err + if err := c.updateReadonlyFile(calendarName, readonly); err != nil { + fmt.Printf("WARN: failed to update calendar read-only file: %v\n", err) + } + + c.calendars[calendarName] = cal + + if c.proxyUrl != nil { + // repair the remote url (set the pure url with auth, without proxy) + if err := c.UpdateRemote(calendarName, repoUrl, readonly); err != nil { + return err + } } return nil @@ -253,11 +288,19 @@ func (c *Core) RenameCalendar(oldName, newName string) error { return fmt.Errorf("failed to rename the encryption key file: %w", err) } } + if c.isCalendarReadonly(oldName) { + if err := c.fs.Rename(fmt.Sprintf("%s.readonly", oldName), fmt.Sprintf("%s.readonly", newName)); err != nil { + _ = c.fs.Rename(newName, oldName) // try to rename repo back + _ = c.fs.Rename(fmt.Sprintf("%s.key", newName), fmt.Sprintf("%s.key", oldName)) // try to rename key back (maybe it didn't exist in the first place, mehh) + return fmt.Errorf("failed to rename the encryption key file: %w", err) + } + } newRepo, err := c.initCalendarRepo(newName) if err != nil { - _ = c.fs.Rename(newName, oldName) // try to rename repo back - _ = c.fs.Rename(fmt.Sprintf("%s.key", newName), fmt.Sprintf("%s.key", oldName)) // try to rename key back (maybe it didn't exist in the first place, mehh) + _ = c.fs.Rename(newName, oldName) // try to rename repo back + _ = c.fs.Rename(fmt.Sprintf("%s.key", newName), fmt.Sprintf("%s.key", oldName)) // try to rename key back (maybe it didn't exist in the first place, mehh) + _ = c.fs.Rename(fmt.Sprintf("%s.readonly", newName), fmt.Sprintf("%s.readonly", oldName)) // try to rename readonly sign back (maybe it didn't exist in the first place, mehh) return fmt.Errorf("failed to load new repo dir: %w", err) } calendar.Name = newName @@ -269,7 +312,7 @@ func (c *Core) RenameCalendar(oldName, newName string) error { return nil } -func (c *Core) UpdateRemote(calendar string, remoteURL *url.URL) error { +func (c *Core) UpdateRemote(calendar string, remoteURL *url.URL, readonly bool) error { cal, ok := c.calendars[calendar] if !ok || cal == nil { return fmt.Errorf("calendar not found: %s", calendar) @@ -302,25 +345,56 @@ func (c *Core) UpdateRemote(calendar string, remoteURL *url.URL) error { return fmt.Errorf("failed to set remote: %w", err) } + if err := c.updateReadonlyFile(calendar, readonly); err != nil { + fmt.Printf("WARN: failed to update calendar read-only file: %v", err) + } + return nil } // ------------------------------------------------ Helpers ------------------------------------------------- func (c *Core) createKeyFile(calendarName, password string) ([]byte, error) { - var key []byte - if len(password) != 0 { - key = encryption.DeriveKey(password, []byte(calendarName)) + key := encryption.DeriveKey(password, []byte(calendarName)) + + keyFile, err := c.fs.Create(fmt.Sprintf("%s.key", calendarName)) + if err != nil { + return nil, fmt.Errorf("failed to create key file: %w", err) + } + defer keyFile.Close() + + if _, err = keyFile.Write(key); err != nil { + return nil, fmt.Errorf("failed to write key to key file: %w", err) + } + + return key, nil +} - keyFile, err := c.fs.Create(fmt.Sprintf("%s.key", calendarName)) +func (c *Core) updateReadonlyFile(calendarName string, readonly bool) error { + path := fmt.Sprintf("%s.readonly", calendarName) + + if readonly { + file, err := c.fs.Create(path) if err != nil { - return nil, fmt.Errorf("failed to create key file: %w", err) + return fmt.Errorf("failed to create readonly file: %w", err) } - defer keyFile.Close() - if _, err = keyFile.Write(key); err != nil { - return nil, fmt.Errorf("failed to write key to key file: %w", err) + if err := file.Close(); err != nil { + return fmt.Errorf("failed to close readonly file: %w", err) } + + return nil } - return key, nil + + if err := c.fs.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove readonly file: %w", err) + } + + return nil +} + +func (c *Core) isCalendarReadonly(calendarName string) bool { + path := fmt.Sprintf("%s.readonly", calendarName) + _, err := c.fs.Stat(path) + return err == nil } diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index aba753a..77beba3 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -18,6 +18,14 @@ func (c *Core) CreateEvent(event Event) (*Event, error) { return nil, fmt.Errorf("an event with this id already exists") } + cal, ok := c.calendars[event.Calendar] + if !ok { + return nil, fmt.Errorf("the specified calendar is missing") + } + if cal.Readonly { + return nil, fmt.Errorf("the specified calendar is read-only") + } + if err := event.Validate(); err != nil { return nil, fmt.Errorf("invalid event: %w", err) } @@ -47,6 +55,14 @@ func (c *Core) UpdateEvent(event Event) (*Event, error) { return nil, fmt.Errorf("no event found with id %q", event.Id) } + cal, ok := c.calendars[event.Calendar] + if !ok { + return nil, fmt.Errorf("the specified calendar is missing") + } + if cal.Readonly { + return nil, fmt.Errorf("the specified calendar is read-only") + } + oldEnd := originalEvent.getTreeEndTime() newEnd := event.getTreeEndTime() @@ -99,7 +115,7 @@ func (c *Core) UpdateRepeatingEvent(old, new Event, strat UpdateStrategy) (*Even if old.Id != new.Id { // check if the event we are changing is the original Parent return nil, fmt.Errorf("invalid update event: id %q does not match parent id %q", old.Id, new.Id) } - if old.IsParent() || new.IsParent() || old.ParentId == uuid.Nil || new.ParentId == uuid.Nil { + if !old.IsChild() || !new.IsChild() { return nil, fmt.Errorf("repeating update requires child events") } if old.ParentId != new.ParentId { @@ -132,6 +148,10 @@ func (c *Core) RemoveEvent(event Event) error { return fmt.Errorf("invalid event: %w", err) } + if cal, ok := c.calendars[event.Calendar]; ok && cal.Readonly { + return fmt.Errorf("the events calendar is read-only") + } + err := c.intervalTree.RemoveEvent(event) if err != nil { return fmt.Errorf("failed to delete event from interval tree: %w", err) @@ -153,6 +173,14 @@ func (c *Core) RemoveRepeatingEvent(event Event, strat UpdateStrategy) error { return fmt.Errorf("invalid event: %w", err) } + if cal, ok := c.calendars[event.Calendar]; ok && cal.Readonly { + return fmt.Errorf("the events calendar is read-only") + } + + if !event.IsChild() { + return errors.New("event has to be a child to be removed using this method") + } + switch strat { case Current: return c.removeCurrentChild(&event) @@ -225,8 +253,8 @@ func (c *Core) GetEvents(from, to time.Time) []Event { From: firstStart, To: firstStart.Add(eventDuration), Calendar: curEvent.Calendar, - Tag: curEvent.Tag, - ParentId: curEvent.Id, + TagId: curEvent.TagId, + ParentId: &curEvent.Id, Repeat: curEvent.Repeat, } // ignore exceptions @@ -246,7 +274,7 @@ func (c *Core) GetEvents(from, to time.Time) []Event { // Updates single generated/child event by adding it to its Parent repeat exceptions and creating a brand new event instead. func (c *Core) updateCurrentChild(updated *Event) (*Event, error) { - parent, ok := c.events[updated.ParentId] + parent, ok := c.events[*updated.ParentId] // we check nil pointer in UpdateRepeatingEvent if !ok || parent == nil || !parent.IsParent() { return nil, fmt.Errorf("no valid parent found") } @@ -261,17 +289,17 @@ func (c *Core) updateCurrentChild(updated *Event) (*Event, error) { } // detach updated from repeating time series - detachedEvent := *updated // shallow copy - detachedEvent.Repeat = nil // not repeating anymore - detachedEvent.ParentId = uuid.Nil // not child anymore - detachedEvent.Id = uuid.Nil // set to nil; CreateEvent will asign a new one + detachedEvent := *updated // shallow copy + detachedEvent.Repeat = nil // not repeating anymore + detachedEvent.ParentId = nil // not child anymore + detachedEvent.Id = uuid.Nil // set to uuid.Nil; CreateEvent will asign a new one return c.CreateEvent(detachedEvent) // save as new } // updateFollowingChildren splits the time series into two by stopping the original parent event from repeating further and creating brand new parent with updated properties. func (c *Core) updateFollowingChildren(old, new *Event) (*Event, error) { - parent, ok := c.events[old.ParentId] + parent, ok := c.events[*old.ParentId] // we check nil pointer in UpdateRepeatingEvent if !ok || parent == nil || !parent.IsParent() { return nil, fmt.Errorf("no valid parent found") } @@ -332,7 +360,7 @@ func (c *Core) updateFollowingChildren(old, new *Event) (*Event, error) { // ------------ create the new continuing series ------------ newEvent := *new newEvent.Id = uuid.New() - newEvent.ParentId = uuid.Nil // this is now its own parent + newEvent.ParentId = nil // this is now its own parent if newEvent.Repeat != nil { repeat := *newEvent.Repeat @@ -378,7 +406,7 @@ func (c *Core) updateFollowingChildren(old, new *Event) (*Event, error) { // Updates the entire repeating series by only modifying the parent. That means all generated child events get updated as well. // Both old and new arguments are child events. func (c *Core) updateAllChildren(old, new *Event) (*Event, error) { - parent, ok := c.events[old.ParentId] + parent, ok := c.events[*old.ParentId] // we check nil pointer in UpdateRepeatingEvent if !ok || parent == nil || !parent.IsParent() { return nil, fmt.Errorf("no valid parent found") } @@ -423,7 +451,7 @@ func (c *Core) updateAllChildren(old, new *Event) (*Event, error) { parent.Description = new.Description parent.From = parent.From.Add(fromDiff) parent.To = parent.To.Add(toDiff) - parent.Tag = new.Tag + parent.TagId = new.TagId parent.Calendar = new.Calendar if needsReindex { @@ -442,7 +470,7 @@ func (c *Core) updateAllChildren(old, new *Event) (*Event, error) { } func (c *Core) removeCurrentChild(event *Event) error { - parent, ok := c.events[event.ParentId] + parent, ok := c.events[*event.ParentId] // we check nil pointer in RemoveRepeatingEvent if !ok || parent == nil || !parent.IsParent() { return fmt.Errorf("no valid parent found") } diff --git a/pkg/core/core_tags.go b/pkg/core/core_tags.go new file mode 100644 index 0000000..a7deaae --- /dev/null +++ b/pkg/core/core_tags.go @@ -0,0 +1,152 @@ +package core + +import ( + "errors" + "fmt" + "os" + "slices" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/google/uuid" +) + +// CreateTag saves one tag into a calendar repository. +func (c *Core) CreateTag(calendar string, tag Tag) (*Tag, error) { + if err := tag.Validate(); err != nil { + return nil, fmt.Errorf("invalid tag: %w", err) + } + + cal, wt, err := c.tagWorktree(calendar) + if err != nil { + return nil, err + } + + if slices.ContainsFunc(cal.Tags, func(t Tag) bool { + return t.Id == tag.Id + }) { + return nil, fmt.Errorf("tag already exists") + } + + gitPath := tag.getPath() + if err := writeTagFile(wt, cal.EncryptionKey, tag); err != nil { + return nil, err + } + if _, err := wt.Add(gitPath); err != nil { + return nil, fmt.Errorf("failed to git add %q: %w", gitPath, err) + } + + cal.Tags = append(cal.Tags, tag) + + return &tag, commitWorktree(wt, fmt.Sprintf("Created tag %s", tag.Id)) +} + +// UpdateTag updates tag based on its Id. +func (c *Core) UpdateTag(calendar string, tag Tag) (*Tag, error) { + if err := tag.Validate(); err != nil { + return nil, fmt.Errorf("invalid tag: %w", err) + } + + cal, wt, err := c.tagWorktree(calendar) + if err != nil { + return nil, err + } + + index := slices.IndexFunc(cal.Tags, func(t Tag) bool { + return t.Id == tag.Id + }) + if index == -1 { + return nil, errors.New("tag with this id does not exist") + } + + gitPath := tag.getPath() + if err := writeTagFile(wt, cal.EncryptionKey, tag); err != nil { + return nil, err + } + if _, err := wt.Add(gitPath); err != nil { + return nil, fmt.Errorf("failed to git add %q: %w", gitPath, err) + } + + cal.Tags[index] = tag // replace + + return &tag, commitWorktree(wt, fmt.Sprintf("Updated tag %s", tag.Id)) +} + +// RemoveTag deletes one tag from a calendar repository. +func (c *Core) RemoveTag(calendar string, id uuid.UUID) error { + if id == uuid.Nil { + return errors.New("invalid tag id") + } + + cal, wt, err := c.tagWorktree(calendar) + if err != nil { + return err + } + + gitPath := Tag{Id: id}.getPath() + if _, err := wt.Remove(gitPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to git remove %q: %w", gitPath, err) + } + + cal.Tags = slices.DeleteFunc(cal.Tags, func(tag Tag) bool { + return tag.Id == id + }) + + return commitWorktree(wt, fmt.Sprintf("Deleted tag %s", id)) +} + +// ------------------------------------------------ Helpers ------------------------------------------------- + +func (c *Core) tagWorktree(calendar string) (*Calendar, *gogit.Worktree, error) { + cal := c.calendars[calendar] + if cal == nil { + return nil, nil, fmt.Errorf("invalid calendar %q", calendar) + } + if cal.repository == nil { + return nil, nil, fmt.Errorf("invalid calendar %q: no repository", calendar) + } + + wt, err := cal.repository.Worktree() + if err != nil { + return nil, nil, fmt.Errorf("failed to get worktree: %w", err) + } + + return cal, wt, nil +} + +func writeTagFile(wt *gogit.Worktree, key []byte, tag Tag) error { + tag.UpdatedAt = time.Now() // force new time + + if err := wt.Filesystem.MkdirAll(TagsDirName, 0o755); err != nil { + return fmt.Errorf("failed to create tags dir %q: %w", TagsDirName, err) + } + + gitPath := tag.getPath() + file, err := wt.Filesystem.Create(gitPath) + if err != nil { + return fmt.Errorf("failed to create tag %q: %w", gitPath, err) + } + defer file.Close() + + if err := tag.WriteToFile(file, key); err != nil { + return fmt.Errorf("failed to write tag %q: %w", gitPath, err) + } + + return nil +} + +func commitWorktree(wt *gogit.Worktree, message string) error { + _, err := wt.Commit(message, &gogit.CommitOptions{ + Author: &object.Signature{ + Name: GitAuthorName, + Email: "", + When: time.Now(), + }, + }) + if err != nil && !errors.Is(err, gogit.ErrEmptyCommit) { + return fmt.Errorf("failed to git commit: %w", err) + } + + return nil +} diff --git a/pkg/core/event.go b/pkg/core/event.go index 782ff84..77c04dc 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -22,10 +22,10 @@ type Event struct { From time.Time `json:"from"` To time.Time `json:"to"` Calendar string `json:"calendar"` // The name of the calendar the event belongs to. - Tag string `json:"tag"` // User-defined category or label. - ParentId uuid.UUID `json:"parent_id"` // Specific for child events. It is uuid.Nil if the event is basic or parent. + TagId *uuid.UUID `json:"tag_id"` // A user-defined tag/category. Can be nil. + ParentId *uuid.UUID `json:"parent_id"` // Specific for child events. It is nil (not uuid.Nil) if the event is basic or parent. Repeat *Repetition `json:"repeat"` - UpdatedAt time.Time `json:"-"` // Used for git conflict resolution; latest wins. Client doesn't need to see this. + UpdatedAt time.Time `json:"-"` // Used for git conflict resolution; latest wins. Client doesn't need to see this -> json:"-". } // Repetition defines the recurrence rules for a Parent event. @@ -88,15 +88,15 @@ func (r *Repetition) Validate() error { } func (e Event) IsBasic() bool { - return !e.IsChild() && !e.IsParent() // e.ParentId == uuid.Nil && e.Repeat == nil + return !e.IsChild() && !e.IsParent() } func (e Event) IsChild() bool { - return e.ParentId != uuid.Nil + return e.ParentId != nil } func (e Event) IsParent() bool { - return e.ParentId == uuid.Nil && e.Repeat != nil + return e.ParentId == nil && e.Repeat != nil } // Returns either the To time.Time for Basic non-repeating event, or calculates the last occurrence of a repeating Parent event and returns its To. diff --git a/pkg/core/event_file.go b/pkg/core/event_file.go index 91c2b77..7fc4248 100644 --- a/pkg/core/event_file.go +++ b/pkg/core/event_file.go @@ -14,20 +14,20 @@ import ( "github.com/google/uuid" ) -// eventFile represents event inside file. It doesn't have an Id and Calendar fields, since they can be derrived from the file path/name itself. -type eventFile struct { +// eventInFile represents event inside file. It doesn't have an Id and Calendar fields, since they can be derrived from the file path/name itself. +type eventInFile struct { Title string `json:"title,omitzero"` Location string `json:"location,omitzero"` Description string `json:"description,omitzero"` From time.Time `json:"from,omitzero"` To time.Time `json:"to,omitzero"` - Tag string `json:"tag,omitzero"` - ParentId uuid.UUID `json:"parent_id,omitzero"` + TagId *uuid.UUID `json:"tag_id,omitzero"` + ParentId *uuid.UUID `json:"parent_id,omitzero"` Repeat *Repetition `json:"repeat,omitzero"` UpdatedAt time.Time `json:"updated_at,omitzero"` } -func (ef eventFile) toEvent(id uuid.UUID, calendar string) Event { +func (ef eventInFile) toEvent(id uuid.UUID, calendar string) Event { return Event{ Id: id, Title: ef.Title, @@ -36,21 +36,21 @@ func (ef eventFile) toEvent(id uuid.UUID, calendar string) Event { From: ef.From, To: ef.To, Calendar: calendar, - Tag: ef.Tag, + TagId: ef.TagId, ParentId: ef.ParentId, Repeat: ef.Repeat, UpdatedAt: ef.UpdatedAt, } } -func (e Event) fileData() eventFile { - return eventFile{ +func (e Event) fileData() eventInFile { + return eventInFile{ Title: e.Title, Location: e.Location, Description: e.Description, From: e.From, To: e.To, - Tag: e.Tag, + TagId: e.TagId, ParentId: e.ParentId, Repeat: e.Repeat, UpdatedAt: e.UpdatedAt, @@ -110,7 +110,7 @@ func (e *Event) LoadFromBytes(raw []byte, name string, calendar string, decrypti return err } - var data eventFile + var data eventInFile if len(decryptionKey) == 0 { // no encryption, just use the plaintext if err := json.Unmarshal(raw, &data); err != nil { diff --git a/pkg/core/git.go b/pkg/core/git.go index a4f26bc..4394ff4 100644 --- a/pkg/core/git.go +++ b/pkg/core/git.go @@ -101,12 +101,27 @@ func mergeOriginMain(repo *gogit.Repository, calendar string, encryptionKey []by path.Ext(gitPath) == ".json" }, UpdatedAt: func(gitPath string, data []byte) (time.Time, error) { - var ev Event - if err := ev.LoadFromBytes(data, path.Base(gitPath), calendar, encryptionKey); err != nil { - return time.Time{}, fmt.Errorf("%s: failed to load event: %w", gitPath, err) + dir := path.Dir(gitPath) + base := path.Base(gitPath) + + switch dir { + case EventsDirName: + var ev Event + if err := ev.LoadFromBytes(data, base, calendar, encryptionKey); err != nil { + return time.Time{}, err + } + return ev.UpdatedAt, nil + + case TagsDirName: + var tg Tag + if err := tg.LoadFromBytes(data, base, encryptionKey); err != nil { + return time.Time{}, err + } + return tg.UpdatedAt, nil + + default: + return time.Time{}, fmt.Errorf("unsupported directory: %s", dir) } - - return ev.UpdatedAt, nil }, }) } diff --git a/pkg/core/tag.go b/pkg/core/tag.go new file mode 100644 index 0000000..ab9f595 --- /dev/null +++ b/pkg/core/tag.go @@ -0,0 +1,161 @@ +package core + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "path" + "strings" + "time" + + "github.com/git-calendar/core/pkg/encryption" + "github.com/go-git/go-billy/v5" + "github.com/google/uuid" +) + +// Tag represents a user-defined category/label for multiple events within one calendar. +type Tag struct { + Id uuid.UUID `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + UpdatedAt time.Time `json:"-"` +} + +func (t *Tag) Validate() error { + if t == nil { + return nil + } + if t.Id != uuid.Nil { + // if id is set + if t.Id.Version() != 4 { // enforce version + return errors.New("unsupported UUID version") + } + } else { // if id is unset + t.Id = uuid.New() // create one if not specified + } + if t.Name == "" { + return errors.New("tag name cannot be empty") + } + if t.Color == "" { + return errors.New("tag color cannot be empty") + } + return nil +} + +func (tag Tag) getPath() string { + return path.Join(TagsDirName, tag.Id.String()+".json") +} + +// ---------------------------------------------------------------- + +// tagInFile represents a tag inside file. +type tagInFile struct { + Name string `json:"name,omitzero"` + Color string `json:"color,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` +} + +func (tf tagInFile) toTag(id uuid.UUID) Tag { + return Tag{ + Id: id, + Name: tf.Name, + Color: tf.Color, + UpdatedAt: tf.UpdatedAt, + } +} + +func (e Tag) fileData() tagInFile { + return tagInFile{ + Name: e.Name, + Color: e.Color, + UpdatedAt: e.UpdatedAt, + } +} + +func (e Tag) WriteToFile(file billy.File, key []byte) error { + if e.Id == uuid.Nil { + return errors.New("tag id has to be set") + } + + // marshal normally + raw, err := json.MarshalIndent(e.fileData(), "", " ") + if err != nil { + return err + } + + if len(key) == 0 { // no encryption, just use the plaintext + _, err = file.Write(raw) + return err + } + + // unmarshal into generic map + var plain map[string]any + if err := json.Unmarshal(raw, &plain); err != nil { + return err + } + + // encrypt everything recursively + encrypted, err := encryption.EncryptFields(plain, key, e.Id[:]) + if err != nil { + return err + } + + // marshal again + finalRaw, err := json.MarshalIndent(encrypted, "", " ") + if err != nil { + return err + } + + _, err = file.Write(finalRaw) + return err +} + +func (e *Tag) LoadFromFile(file billy.File, decryptionKey []byte) error { + raw, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", file.Name(), err) + } + + return e.LoadFromBytes(raw, file.Name(), decryptionKey) +} + +func (e *Tag) LoadFromBytes(raw []byte, filename string, decryptionKey []byte) error { + id, err := uuid.Parse(strings.TrimSuffix(path.Base(filename), ".json")) // get tag id from the filename + if err != nil { + return err + } + + var data tagInFile + + if len(decryptionKey) == 0 { // no encryption, just use the plaintext + if err := json.Unmarshal(raw, &data); err != nil { + return err + } + + *e = data.toTag(id) + return nil + } + + var encrypted map[string]any + if err := json.Unmarshal(raw, &encrypted); err != nil { + return err + } + + decrypted, err := encryption.DecryptFields(encrypted, decryptionKey, id[:]) + if err != nil { + return err + } + + // eww (map to struct conversion) + tmp, err := json.Marshal(decrypted) + if err != nil { + return err + } + if err := json.Unmarshal(tmp, &data); err != nil { + return err + } + + *e = data.toTag(id) + return nil +} diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 064a6e2..0f64b88 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -229,6 +229,7 @@ func splitExceptions(exceptions []uuid.UUID, cutoff time.Time) (before, after [] return } +// repeatRuleChanged checks if Repetition differs. func repeatRuleChanged(a, b *Repetition) bool { if a == nil || b == nil { return a != b diff --git a/pkg/gitmerge/merge.go b/pkg/gitmerge/merge.go index fa0cb2c..0ca3fd7 100644 --- a/pkg/gitmerge/merge.go +++ b/pkg/gitmerge/merge.go @@ -36,32 +36,46 @@ func MergeRemoteIntoBranch(repo *gogit.Repository, opts Options) error { if err != nil { return err } - if localCommit.Hash == remoteCommit.Hash { + + if localCommit == nil && remoteCommit == nil { + // completely empty repo local as well as remote + return nil + } + + if localCommit != nil && remoteCommit != nil && localCommit.Hash == remoteCommit.Hash { + // same commits, no need to merge return nil } var baseTree *object.Tree - baseCommit, err := mergeBase(localCommit, remoteCommit) - if errors.Is(err, errNoMergeBase) { - // unrelated histories -> treat this as a merge against an empty base - baseTree = nil - } else if err != nil { - return err - } else { - baseTree, err = baseCommit.Tree() - if err != nil { - return fmt.Errorf("load base tree: %w", err) + if localCommit != nil && remoteCommit != nil { + baseCommit, err := mergeBase(localCommit, remoteCommit) + if errors.Is(err, errNoMergeBase) { + baseTree = nil // unrelated histories -> empty base + } else if err != nil { + return err + } else { + baseTree, err = baseCommit.Tree() + if err != nil { + return fmt.Errorf("load base tree: %w", err) + } } } - localTree, err := localCommit.Tree() - if err != nil { - return fmt.Errorf("load local tree: %w", err) + var localTree *object.Tree + if localCommit != nil { + localTree, err = localCommit.Tree() + if err != nil { + return fmt.Errorf("load local tree: %w", err) + } } - remoteTree, err := remoteCommit.Tree() - if err != nil { - return fmt.Errorf("load remote tree: %w", err) + var remoteTree *object.Tree + if remoteCommit != nil { + remoteTree, err = remoteCommit.Tree() + if err != nil { + return fmt.Errorf("load remote tree: %w", err) + } } paths, err := collectPaths(opts.IncludePath, baseTree, localTree, remoteTree) @@ -93,13 +107,13 @@ func MergeRemoteIntoBranch(repo *gogit.Repository, opts Options) error { commitMsg := fmt.Sprintf("Merge '%s/%s' (LWW)", opts.RemoteName, opts.BranchName) _, err = wt.Commit(commitMsg, &gogit.CommitOptions{ - Parents: []plumbing.Hash{localCommit.Hash, remoteCommit.Hash}, + Parents: commitParents(localCommit, remoteCommit), + AllowEmptyCommits: true, Author: &object.Signature{ Name: opts.AuthorName, Email: opts.AuthorEmail, When: time.Now(), }, - AllowEmptyCommits: true, }) if err != nil { return fmt.Errorf("failed to commit merge: %w", err) @@ -108,13 +122,13 @@ func MergeRemoteIntoBranch(repo *gogit.Repository, opts Options) error { return nil } -// applyLWW applies last-write-wins strategy to an event file from three versions (base, local, remote). +// applyLWW applies last-write-wins strategy to a file from three versions (base, local, remote). func applyLWW(wt *gogit.Worktree, gitPath string, base, local, remote fileVersion) error { switch { case base.exists && !remote.exists: // remote deleted -> delete wins if local.exists { if _, err := wt.Remove(gitPath); err != nil { - return fmt.Errorf("%s: failed to remove an event which was deleted on remote: %w", gitPath, err) + return fmt.Errorf("%s: failed to remove a file which was deleted on remote: %w", gitPath, err) } } @@ -186,9 +200,9 @@ func GetCommits( branchName string, remoteName string, ) (local, remote *object.Commit, err error) { - localRef, err := localBranchRef(repo, branchName) + local, err = GetLocalCommit(repo, branchName) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("failed to load local commit: %w", err) } remoteRef, err := remoteBranchRef(repo, remoteName, branchName) @@ -196,11 +210,6 @@ func GetCommits( return nil, nil, err } - local, err = repo.CommitObject(localRef.Hash()) - if err != nil { - return nil, nil, fmt.Errorf("failed to load local commit: %w", err) - } - remote, err = repo.CommitObject(remoteRef.Hash()) if err != nil { return nil, nil, fmt.Errorf("failed to load remote commit: %w", err) @@ -209,6 +218,23 @@ func GetCommits( return local, remote, nil } +func GetLocalCommit(repo *gogit.Repository, branchName string) (*object.Commit, error) { + ref, err := localBranchRef(repo, branchName) + if err != nil { + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return nil, nil + } + return nil, err + } + + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return nil, err + } + + return commit, nil +} + func localBranchRef(repo *gogit.Repository, branchName string) (*plumbing.Reference, error) { name := plumbing.NewBranchReferenceName(branchName) @@ -331,3 +357,17 @@ func collectPaths(include IncludePathFunc, trees ...*object.Tree) ([]string, err func billyPath(fs billy.Filesystem, gitPath string) string { return fs.Join(strings.Split(path.Clean(gitPath), "/")...) } + +func commitParents(local, remote *object.Commit) []plumbing.Hash { + parents := make([]plumbing.Hash, 0, 2) + + if local != nil { + parents = append(parents, local.Hash) + } + + if remote != nil && (local == nil || remote.Hash != local.Hash) { + parents = append(parents, remote.Hash) + } + + return parents +}