From a9993e081ef061c13a245abf6d6ee35781e8329c Mon Sep 17 00:00:00 2001 From: firu11 Date: Sun, 21 Jun 2026 16:36:30 +0200 Subject: [PATCH 01/21] more merging --- pkg/core/core.go | 20 +++++++++- pkg/gitmerge/merge.go | 92 +++++++++++++++++++++++++++++++------------ 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/pkg/core/core.go b/pkg/core/core.go index f796704..ace2a6c 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" ) @@ -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) diff --git a/pkg/gitmerge/merge.go b/pkg/gitmerge/merge.go index fa0cb2c..60c4f42 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) @@ -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, fmt.Errorf("failed to load local commit: %w", 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 +} From 9e93ce937cc25cfeeb4d04c676d10d334c126b76 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sun, 21 Jun 2026 16:39:04 +0200 Subject: [PATCH 02/21] first step to metadata --- pkg/core/constants.go | 5 +- pkg/core/core_calendars.go | 137 +++++++++++++++++++++++++++++-------- pkg/core/metadata.go | 65 ++++++++++++++++++ 3 files changed, 178 insertions(+), 29 deletions(-) create mode 100644 pkg/core/metadata.go diff --git a/pkg/core/constants.go b/pkg/core/constants.go index 1a00ff1..fd36b73 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -1,8 +1,9 @@ package core const ( - IndexFileName string = "index.json" - RichIndexFileName string = "index-rich.json" + MetadataFileName string = "metadata.json" + // IndexFileName string = "index.json" + // RichIndexFileName string = "index-rich.json" EventsDirName string = "events" diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index 97ac6e6..bea7a04 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,13 +42,22 @@ 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) } + meta := metadata{ + Tags: slices.Clone(cal.Tags), + Encrypted: cal.IsEncrypted(), + } + if err := meta.Save(repo); err != nil { + c.RemoveCalendar(name) // cleanup + return fmt.Errorf("failed to save metadata to file: %w", err) + } + c.calendars[name] = &cal return nil } @@ -65,6 +78,7 @@ func (c *Core) ListCalendars() ([]Calendar, error) { Tags: slices.Clone(cal.Tags), EncryptionKey: slices.Clone(cal.EncryptionKey), repository: cal.repository, + Readonly: cal.Readonly, }) } @@ -93,6 +107,7 @@ 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 { @@ -102,9 +117,16 @@ func (c *Core) LoadCalendars() error { keyFile.Close() } + // load metadata + var meta metadata + if err := meta.Load(repo); err != nil { + fmt.Printf("failed to load metadata for %q repository: %v\n", name, err) + continue + } + c.calendars[name] = &Calendar{ Name: name, - Tags: nil, // TODO: load tags + Tags: meta.Tags, EncryptionKey: key, repository: repo, } @@ -182,31 +204,53 @@ 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 + // load metadata + var meta metadata + if err := meta.Load(repo); err != nil { + c.RemoveCalendar(calendarName) + return fmt.Errorf("failed to load metadata for %q repository: %w\n", calendarName, err) + } + + var key []byte + if meta.Encrypted { + if len(password) == 0 { + c.RemoveCalendar(calendarName) + return fmt.Errorf("calendar %q is encrypted, but you did not specify a decryption password", calendarName) + } + if key, err = c.createKeyFile(calendarName, password); err != nil { + return err + } } c.calendars[calendarName] = &Calendar{ Name: calendarName, - Tags: nil, // TODO: load tags + Tags: meta.Tags, EncryptionKey: key, - repository: newRepo, + repository: repo, } - // 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", err) + } + + 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 +297,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 +321,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 +354,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 +} + +func (c *Core) updateReadonlyFile(calendarName string, readonly bool) error { + path := fmt.Sprintf("%s.readonly", calendarName) - keyFile, err := c.fs.Create(fmt.Sprintf("%s.key", 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/metadata.go b/pkg/core/metadata.go new file mode 100644 index 0000000..c04dba8 --- /dev/null +++ b/pkg/core/metadata.go @@ -0,0 +1,65 @@ +package core + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + gogit "github.com/go-git/go-git/v5" +) + +type metadata struct { + Tags []Tag `json:"tags"` + Encrypted bool `json:"encrypted"` +} + +func (m *metadata) Load(repo *gogit.Repository) error { + if repo == nil { + return errors.New("repo can't be nil") + } + + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get the worktree: %w\n", err) + } + + metaFile, err := wt.Filesystem.Open(MetadataFileName) + if err != nil { + return fmt.Errorf("failed to open metadata file: %w", err) + } + + data, err := io.ReadAll(metaFile) + if err != nil { + return fmt.Errorf("failed to read metadata file: %w", err) + } + + return json.Unmarshal(data, m) +} + +func (m metadata) Save(repo *gogit.Repository) error { + if repo == nil { + return errors.New("repo can't be nil") + } + + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get the worktree: %w\n", err) + } + + metaFile, err := wt.Filesystem.Create(MetadataFileName) + if err != nil { + return fmt.Errorf("failed to create metadata file: %w", err) + } + + data, err := json.Marshal(m) + if err != nil { + return err + } + + if _, err := metaFile.Write(data); err != nil { + return fmt.Errorf("failed to write to metadata file: %w", err) + } + + return nil +} From b8cd32fbaac70b6c2462066d605a8cdd990bb662 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sun, 21 Jun 2026 16:39:48 +0200 Subject: [PATCH 03/21] readonly flag --- cmd/wasm/main.go | 4 ++-- e2e/calendars_test.go | 2 +- e2e/git_test.go | 10 +++++----- pkg/api/api.go | 8 ++++---- pkg/core/core_calendars.go | 4 +++- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 83b1d84..c5a8504 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 { diff --git a/e2e/calendars_test.go b/e2e/calendars_test.go index b5986de..89cf6a5 100644 --- a/e2e/calendars_test.go +++ b/e2e/calendars_test.go @@ -98,7 +98,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/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/pkg/api/api.go b/pkg/api/api.go index 5e8a361..a9192ad 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -52,20 +52,20 @@ func (a *Api) ExportZip(calendar string) ([]byte, error) { return a.inner.Export // ------------------------------ 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) { diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index bea7a04..7df5e7c 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -129,6 +129,7 @@ func (c *Core) LoadCalendars() error { Tags: meta.Tags, EncryptionKey: key, repository: repo, + Readonly: c.isCalendarReadonly(name), } } @@ -177,7 +178,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") @@ -240,6 +241,7 @@ func (c *Core) CloneCalendar(repoUrl *url.URL, password string) error { Tags: meta.Tags, EncryptionKey: key, repository: repo, + Readonly: readonly, } if err := c.updateReadonlyFile(calendarName, readonly); err != nil { From cc5db8da391616c00e27a22458430929d3c289d4 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sun, 21 Jun 2026 16:40:27 +0200 Subject: [PATCH 04/21] update json api models --- pkg/api/{event.go => models.go} | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) rename pkg/api/{event.go => models.go} (68%) diff --git a/pkg/api/event.go b/pkg/api/models.go similarity index 68% rename from pkg/api/event.go rename to pkg/api/models.go index 6afd3b1..b2a9592 100644 --- a/pkg/api/event.go +++ b/pkg/api/models.go @@ -23,3 +23,19 @@ 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 { + Name string + Color string +} From a706913fd2a512d6ba80c5aa87a60a4d231da714 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 23 Jun 2026 12:15:08 +0200 Subject: [PATCH 05/21] read-only check on event ops. --- pkg/core/core_events.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index 6042667..451d188 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -19,6 +19,10 @@ func (c *Core) CreateEvent(event Event) (*Event, error) { return nil, fmt.Errorf("an event with this id already exists") } + if cal, ok := c.calendars[event.Calendar]; !ok || cal.Readonly { + return nil, fmt.Errorf("the specified calendar is either missing or is read-only") + } + if err := event.Validate(); err != nil { return nil, fmt.Errorf("invalid event: %w", err) } @@ -48,6 +52,10 @@ func (c *Core) UpdateEvent(event Event) (*Event, error) { return nil, fmt.Errorf("no event found with id %q", event.Id) } + if cal, ok := c.calendars[event.Calendar]; !ok || cal.Readonly { + return nil, fmt.Errorf("the specified calendar is either missing or is read-only") + } + oldEnd := originalEvent.getTreeEndTime() newEnd := event.getTreeEndTime() @@ -100,6 +108,9 @@ 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 cal, ok := c.calendars[new.Calendar]; !ok || cal.Readonly { + return nil, fmt.Errorf("the specified calendar is either missing or is read-only") + } switch strat { case Current: @@ -119,6 +130,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) @@ -140,6 +155,10 @@ 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") + } + switch strat { case Current: return c.removeCurrentChild(&event) From e885a48630fd148d93a16aea4db35fe00e270a90 Mon Sep 17 00:00:00 2001 From: firu11 Date: Fri, 26 Jun 2026 18:48:25 +0200 Subject: [PATCH 06/21] tags as separate files, screw metadata --- pkg/core/calendar.go | 16 ++-- pkg/core/constants.go | 2 +- pkg/core/core_calendars.go | 39 ++------ pkg/core/core_tags.go | 180 +++++++++++++++++++++++++++++++++++++ pkg/core/metadata.go | 65 -------------- pkg/core/tag.go | 155 ++++++++++++++++++++++++++++++++ 6 files changed, 351 insertions(+), 106 deletions(-) create mode 100644 pkg/core/core_tags.go delete mode 100644 pkg/core/metadata.go create mode 100644 pkg/core/tag.go diff --git a/pkg/core/calendar.go b/pkg/core/calendar.go index 91e51c2..1226cc6 100644 --- a/pkg/core/calendar.go +++ b/pkg/core/calendar.go @@ -18,17 +18,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 +62,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"` } diff --git a/pkg/core/constants.go b/pkg/core/constants.go index fd36b73..4c90680 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -1,11 +1,11 @@ package core const ( - MetadataFileName string = "metadata.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_calendars.go b/pkg/core/core_calendars.go index 7df5e7c..a49c8ce 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -49,15 +49,6 @@ func (c *Core) CreateCalendar(name, password string) error { return fmt.Errorf("calendar invalid: %w", err) } - meta := metadata{ - Tags: slices.Clone(cal.Tags), - Encrypted: cal.IsEncrypted(), - } - if err := meta.Save(repo); err != nil { - c.RemoveCalendar(name) // cleanup - return fmt.Errorf("failed to save metadata to file: %w", err) - } - c.calendars[name] = &cal return nil } @@ -117,16 +108,11 @@ func (c *Core) LoadCalendars() error { keyFile.Close() } - // load metadata - var meta metadata - if err := meta.Load(repo); err != nil { - fmt.Printf("failed to load metadata for %q repository: %v\n", name, err) - continue - } + // load tags TODO c.calendars[name] = &Calendar{ Name: name, - Tags: meta.Tags, + Tags: nil, // TODO EncryptionKey: key, repository: repo, Readonly: c.isCalendarReadonly(name), @@ -218,27 +204,16 @@ func (c *Core) CloneCalendar(repoUrl *url.URL, password string, readonly bool) e return fmt.Errorf("git clone failed: %w", err) } - // load metadata - var meta metadata - if err := meta.Load(repo); err != nil { - c.RemoveCalendar(calendarName) - return fmt.Errorf("failed to load metadata for %q repository: %w\n", calendarName, err) - } + // load tags TODO - var key []byte - if meta.Encrypted { - if len(password) == 0 { - c.RemoveCalendar(calendarName) - return fmt.Errorf("calendar %q is encrypted, but you did not specify a decryption password", calendarName) - } - if key, err = c.createKeyFile(calendarName, password); err != nil { - return err - } + key, err := c.createKeyFile(calendarName, password) + if err != nil { + return err } c.calendars[calendarName] = &Calendar{ Name: calendarName, - Tags: meta.Tags, + Tags: nil, // TODO EncryptionKey: key, repository: repo, Readonly: readonly, diff --git a/pkg/core/core_tags.go b/pkg/core/core_tags.go new file mode 100644 index 0000000..ba1a050 --- /dev/null +++ b/pkg/core/core_tags.go @@ -0,0 +1,180 @@ +package core + +import ( + "errors" + "fmt" + "os" + "path" + "slices" + "strings" + "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) error { + if err := tag.Validate(); err != nil { + return fmt.Errorf("invalid tag: %w", err) + } + + cal, wt, err := c.tagWorktree(calendar) + if err != nil { + return err + } + + if slices.ContainsFunc(cal.Tags, func(t Tag) bool { + return t.Id == tag.Id + }) { + return fmt.Errorf("tag already exists: %w", err) + } + + gitPath := tag.getPath() + if err := writeTagFile(wt, cal.EncryptionKey, tag); err != nil { + return err + } + if _, err := wt.Add(gitPath); err != nil { + return fmt.Errorf("failed to git add %q: %w", gitPath, err) + } + + cal.Tags = append(cal.Tags, tag) + + return commitWorktree(wt, fmt.Sprintf("Updated tag %s", tag.Id)) +} + +// LoadTags loads tags for selected calendars. If none are passed, all calendars are loaded. +func (c *Core) LoadTags(calendars ...string) error { + if len(calendars) == 0 { + for calendar := range c.calendars { + calendars = append(calendars, calendar) + } + } + + for _, calendar := range calendars { + cal, wt, err := c.tagWorktree(calendar) + if err != nil { + return err + } + + tags, err := readTagFiles(wt, cal.EncryptionKey) + if err != nil { + return fmt.Errorf("failed to load tags for calendar %q: %w", calendar, err) + } + + cal.Tags = tags + } + + return nil +} + +// DeleteTag deletes one tag from a calendar repository. +func (c *Core) DeleteTag(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 { + 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 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 +} + +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/metadata.go b/pkg/core/metadata.go deleted file mode 100644 index c04dba8..0000000 --- a/pkg/core/metadata.go +++ /dev/null @@ -1,65 +0,0 @@ -package core - -import ( - "encoding/json" - "errors" - "fmt" - "io" - - gogit "github.com/go-git/go-git/v5" -) - -type metadata struct { - Tags []Tag `json:"tags"` - Encrypted bool `json:"encrypted"` -} - -func (m *metadata) Load(repo *gogit.Repository) error { - if repo == nil { - return errors.New("repo can't be nil") - } - - wt, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get the worktree: %w\n", err) - } - - metaFile, err := wt.Filesystem.Open(MetadataFileName) - if err != nil { - return fmt.Errorf("failed to open metadata file: %w", err) - } - - data, err := io.ReadAll(metaFile) - if err != nil { - return fmt.Errorf("failed to read metadata file: %w", err) - } - - return json.Unmarshal(data, m) -} - -func (m metadata) Save(repo *gogit.Repository) error { - if repo == nil { - return errors.New("repo can't be nil") - } - - wt, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get the worktree: %w\n", err) - } - - metaFile, err := wt.Filesystem.Create(MetadataFileName) - if err != nil { - return fmt.Errorf("failed to create metadata file: %w", err) - } - - data, err := json.Marshal(m) - if err != nil { - return err - } - - if _, err := metaFile.Write(data); err != nil { - return fmt.Errorf("failed to write to metadata file: %w", err) - } - - return nil -} diff --git a/pkg/core/tag.go b/pkg/core/tag.go new file mode 100644 index 0000000..e539a64 --- /dev/null +++ b/pkg/core/tag.go @@ -0,0 +1,155 @@ +package core + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "path" + "strings" + + "github.com/git-calendar/core/pkg/encryption" + "github.com/go-git/go-billy/v5" + "github.com/google/uuid" +) + +type Tag struct { + Id uuid.UUID `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +func (t *Tag) Validate() error { + if t == nil { + return nil + } + if t.Id != uuid.Nil { + // if id is set + if t.Id.Version() != 4 && t.Id.Version() != 8 { // 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") +} + +// ---------------------------------------------------------------- + +// tagFile represents a tag inside file. +type tagInFile struct { + Name string `json:"name,omitzero"` + Color string `json:"color,omitzero"` +} + +func (tf tagInFile) toTag(id uuid.UUID) Tag { + return Tag{ + Id: id, + Name: tf.Name, + Color: tf.Color, + } +} + +func (e Tag) fileData() tagInFile { + return tagInFile{ + Name: e.Name, + Color: e.Color, + } +} + +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 +} From b70ce5c6a74b0fc39a9126c0b4c4e13361cb0246 Mon Sep 17 00:00:00 2001 From: firu11 Date: Fri, 26 Jun 2026 19:39:13 +0200 Subject: [PATCH 07/21] small changes --- pkg/core/core.go | 4 ++-- pkg/core/event_file.go | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/core/core.go b/pkg/core/core.go index ace2a6c..6205a09 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -29,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. diff --git a/pkg/core/event_file.go b/pkg/core/event_file.go index 91c2b77..a58fded 100644 --- a/pkg/core/event_file.go +++ b/pkg/core/event_file.go @@ -14,8 +14,8 @@ 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"` @@ -27,7 +27,7 @@ type eventFile struct { 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, @@ -43,8 +43,8 @@ func (ef eventFile) toEvent(id uuid.UUID, calendar string) Event { } } -func (e Event) fileData() eventFile { - return eventFile{ +func (e Event) fileData() eventInFile { + return eventInFile{ Title: e.Title, Location: e.Location, Description: e.Description, @@ -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 { From a5a4265533fbff67b8e1c2bcc37365fa7e9bae55 Mon Sep 17 00:00:00 2001 From: firu11 Date: Fri, 26 Jun 2026 19:39:40 +0200 Subject: [PATCH 08/21] update notes --- notes.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/notes.md b/notes.md index 88ae514..4e6b210 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 @@ -28,7 +28,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 @@ -47,15 +47,20 @@ │ ├── .git/ │ ├── events/ │ │ └── .json +│ ├── tags/ +│ │ └── .json │ ├── index.jsonl │ └── index-rich.jsonl ├── shared/ │ ├── .git/ │ ├── events/ │ │ └── .json +│ ├── tags/ +│ │ └── .json │ ├── index.jsonl │ └── index-rich.jsonl ├── main.key +├── shared.readonly └── shared.key ``` From 4ff477acc63c0138b5c5102681d4957d2512953f Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 27 Jun 2026 22:22:37 +0200 Subject: [PATCH 09/21] tags methods --- pkg/core/calendar.go | 56 +++++++++++++++++++++++++++++++-- pkg/core/core_calendars.go | 38 ++++++++++++++--------- pkg/core/core_tags.go | 63 +------------------------------------- pkg/core/event.go | 2 +- pkg/core/event_file.go | 2 +- pkg/gitmerge/merge.go | 6 ++-- 6 files changed, 84 insertions(+), 83 deletions(-) diff --git a/pkg/core/calendar.go b/pkg/core/calendar.go index 1226cc6..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" ) @@ -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/core_calendars.go b/pkg/core/core_calendars.go index a49c8ce..1f1259c 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -103,20 +103,24 @@ func (c *Core) LoadCalendars() error { 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() } - // load tags TODO - - c.calendars[name] = &Calendar{ + cal := &Calendar{ Name: name, - Tags: nil, // TODO 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 @@ -126,7 +130,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 } @@ -204,25 +208,31 @@ func (c *Core) CloneCalendar(repoUrl *url.URL, password string, readonly bool) e return fmt.Errorf("git clone failed: %w", err) } - // load tags TODO - - 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 EncryptionKey: key, 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) + } + if err := c.updateReadonlyFile(calendarName, readonly); err != nil { - fmt.Printf("WARN: failed to update calendar read-only file: %v", err) + 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 { diff --git a/pkg/core/core_tags.go b/pkg/core/core_tags.go index ba1a050..3d87bcd 100644 --- a/pkg/core/core_tags.go +++ b/pkg/core/core_tags.go @@ -4,9 +4,7 @@ import ( "errors" "fmt" "os" - "path" "slices" - "strings" "time" gogit "github.com/go-git/go-git/v5" @@ -41,32 +39,7 @@ func (c *Core) CreateTag(calendar string, tag Tag) error { cal.Tags = append(cal.Tags, tag) - return commitWorktree(wt, fmt.Sprintf("Updated tag %s", tag.Id)) -} - -// LoadTags loads tags for selected calendars. If none are passed, all calendars are loaded. -func (c *Core) LoadTags(calendars ...string) error { - if len(calendars) == 0 { - for calendar := range c.calendars { - calendars = append(calendars, calendar) - } - } - - for _, calendar := range calendars { - cal, wt, err := c.tagWorktree(calendar) - if err != nil { - return err - } - - tags, err := readTagFiles(wt, cal.EncryptionKey) - if err != nil { - return fmt.Errorf("failed to load tags for calendar %q: %w", calendar, err) - } - - cal.Tags = tags - } - - return nil + return commitWorktree(wt, fmt.Sprintf("Created tag %s", tag.Id)) } // DeleteTag deletes one tag from a calendar repository. @@ -130,40 +103,6 @@ func writeTagFile(wt *gogit.Worktree, key []byte, tag Tag) error { 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 -} - func commitWorktree(wt *gogit.Worktree, message string) error { _, err := wt.Commit(message, &gogit.CommitOptions{ Author: &object.Signature{ diff --git a/pkg/core/event.go b/pkg/core/event.go index 3344df8..d63ee01 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -22,7 +22,7 @@ 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. + Tag uuid.UUID `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. Repeat *Repetition `json:"repeat"` UpdatedAt time.Time `json:"updated_at"` // Used for git conflict resolution; latest wins. diff --git a/pkg/core/event_file.go b/pkg/core/event_file.go index a58fded..3595057 100644 --- a/pkg/core/event_file.go +++ b/pkg/core/event_file.go @@ -21,7 +21,7 @@ type eventInFile struct { Description string `json:"description,omitzero"` From time.Time `json:"from,omitzero"` To time.Time `json:"to,omitzero"` - Tag string `json:"tag,omitzero"` + Tag uuid.UUID `json:"tag,omitzero"` ParentId uuid.UUID `json:"parent_id,omitzero"` Repeat *Repetition `json:"repeat,omitzero"` UpdatedAt time.Time `json:"updated_at,omitzero"` diff --git a/pkg/gitmerge/merge.go b/pkg/gitmerge/merge.go index 60c4f42..0ca3fd7 100644 --- a/pkg/gitmerge/merge.go +++ b/pkg/gitmerge/merge.go @@ -122,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) } } @@ -229,7 +229,7 @@ func GetLocalCommit(repo *gogit.Repository, branchName string) (*object.Commit, commit, err := repo.CommitObject(ref.Hash()) if err != nil { - return nil, fmt.Errorf("failed to load local commit: %w", err) + return nil, err } return commit, nil From 3d0a7747c40cf1a76cb880ad7e3c687e0f86c800 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 27 Jun 2026 22:24:06 +0200 Subject: [PATCH 10/21] small changes --- notes.md | 4 ++-- pkg/core/core.go | 17 ++++++++++++++--- pkg/core/core_events.go | 24 ++++++++++++++++++------ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/notes.md b/notes.md index 4e6b210..fbf5813 100644 --- a/notes.md +++ b/notes.md @@ -43,7 +43,7 @@ ``` .git-calendar-data/ -├── main/ +├── default/ │ ├── .git/ │ ├── events/ │ │ └── .json @@ -59,7 +59,7 @@ │ │ └── .json │ ├── index.jsonl │ └── index-rich.jsonl -├── main.key +├── default.key ├── shared.readonly └── shared.key ``` diff --git a/pkg/core/core.go b/pkg/core/core.go index 6205a09..3a18c2b 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -72,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 @@ -125,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_events.go b/pkg/core/core_events.go index 451d188..677e92e 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -19,8 +19,12 @@ func (c *Core) CreateEvent(event Event) (*Event, error) { return nil, fmt.Errorf("an event with this id already exists") } - if cal, ok := c.calendars[event.Calendar]; !ok || cal.Readonly { - return nil, fmt.Errorf("the specified calendar is either missing or is read-only") + 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 { @@ -52,8 +56,12 @@ func (c *Core) UpdateEvent(event Event) (*Event, error) { return nil, fmt.Errorf("no event found with id %q", event.Id) } - if cal, ok := c.calendars[event.Calendar]; !ok || cal.Readonly { - return nil, fmt.Errorf("the specified calendar is either missing or is read-only") + 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() @@ -108,8 +116,12 @@ 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 cal, ok := c.calendars[new.Calendar]; !ok || cal.Readonly { - return nil, fmt.Errorf("the specified calendar is either missing or is read-only") + cal, ok := c.calendars[new.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") } switch strat { From c7b19e426d49097683892b6becb7b9de482e6e03 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sun, 28 Jun 2026 17:22:09 +0200 Subject: [PATCH 11/21] renaming --- pkg/api/models.go | 3 ++- pkg/core/core_events.go | 4 ++-- pkg/core/core_tags.go | 4 ++-- pkg/core/event.go | 4 ++-- pkg/core/event_file.go | 6 +++--- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pkg/api/models.go b/pkg/api/models.go index b2a9592..ae71bc7 100644 --- a/pkg/api/models.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 } @@ -36,6 +36,7 @@ type Calendar struct { } type Tag struct { + Id string Name string Color string } diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index 677e92e..f4f604e 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -243,7 +243,7 @@ func (c *Core) GetEvents(from, to time.Time) []Event { From: firstStart, To: firstStart.Add(eventDuration), Calendar: curEvent.Calendar, - Tag: curEvent.Tag, + TagId: curEvent.TagId, ParentId: curEvent.Id, Repeat: curEvent.Repeat, } @@ -410,7 +410,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 { diff --git a/pkg/core/core_tags.go b/pkg/core/core_tags.go index 3d87bcd..a4c4a9c 100644 --- a/pkg/core/core_tags.go +++ b/pkg/core/core_tags.go @@ -42,8 +42,8 @@ func (c *Core) CreateTag(calendar string, tag Tag) error { return commitWorktree(wt, fmt.Sprintf("Created tag %s", tag.Id)) } -// DeleteTag deletes one tag from a calendar repository. -func (c *Core) DeleteTag(calendar string, id uuid.UUID) error { +// 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") } diff --git a/pkg/core/event.go b/pkg/core/event.go index d63ee01..9488aae 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -21,8 +21,8 @@ type Event struct { Description string `json:"description"` From time.Time `json:"from"` To time.Time `json:"to"` - Calendar string `json:"calendar"` // The name of the calendar the event belongs to. - Tag uuid.UUID `json:"tag"` // User-defined category or label. + Calendar string `json:"calendar"` // The name of the calendar the event belongs to. + TagId uuid.UUID `json:"tag_id"` ParentId uuid.UUID `json:"parent_id"` // Specific for child events. It is uuid.Nil if the event is basic or parent. Repeat *Repetition `json:"repeat"` UpdatedAt time.Time `json:"updated_at"` // Used for git conflict resolution; latest wins. diff --git a/pkg/core/event_file.go b/pkg/core/event_file.go index 3595057..678517b 100644 --- a/pkg/core/event_file.go +++ b/pkg/core/event_file.go @@ -21,7 +21,7 @@ type eventInFile struct { Description string `json:"description,omitzero"` From time.Time `json:"from,omitzero"` To time.Time `json:"to,omitzero"` - Tag uuid.UUID `json:"tag,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"` @@ -36,7 +36,7 @@ func (ef eventInFile) 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, @@ -50,7 +50,7 @@ func (e Event) fileData() eventInFile { Description: e.Description, From: e.From, To: e.To, - Tag: e.Tag, + TagId: e.TagId, ParentId: e.ParentId, Repeat: e.Repeat, UpdatedAt: e.UpdatedAt, From acb522fa5d282e67c0f307508add4974bb281d51 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sun, 28 Jun 2026 17:22:43 +0200 Subject: [PATCH 12/21] add tag methods to api --- cmd/wasm/main.go | 10 ++++++++++ pkg/api/api.go | 35 ++++++++++++++++++++++++++++++++--- pkg/core/core_tags.go | 14 +++++++------- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index c5a8504..fb65779 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -90,6 +90,16 @@ 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()) + }) + }), + "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/pkg/api/api.go b/pkg/api/api.go index a9192ad..7ece625 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -43,9 +43,7 @@ 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) } @@ -174,6 +172,37 @@ 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 event 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) 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/core/core_tags.go b/pkg/core/core_tags.go index a4c4a9c..8c39791 100644 --- a/pkg/core/core_tags.go +++ b/pkg/core/core_tags.go @@ -13,33 +13,33 @@ import ( ) // CreateTag saves one tag into a calendar repository. -func (c *Core) CreateTag(calendar string, tag Tag) error { +func (c *Core) CreateTag(calendar string, tag Tag) (*Tag, error) { if err := tag.Validate(); err != nil { - return fmt.Errorf("invalid tag: %w", err) + return nil, fmt.Errorf("invalid tag: %w", err) } cal, wt, err := c.tagWorktree(calendar) if err != nil { - return err + return nil, err } if slices.ContainsFunc(cal.Tags, func(t Tag) bool { return t.Id == tag.Id }) { - return fmt.Errorf("tag already exists: %w", err) + return nil, fmt.Errorf("tag already exists") } gitPath := tag.getPath() if err := writeTagFile(wt, cal.EncryptionKey, tag); err != nil { - return err + return nil, err } if _, err := wt.Add(gitPath); err != nil { - return fmt.Errorf("failed to git add %q: %w", gitPath, err) + return nil, fmt.Errorf("failed to git add %q: %w", gitPath, err) } cal.Tags = append(cal.Tags, tag) - return commitWorktree(wt, fmt.Sprintf("Created tag %s", tag.Id)) + return &tag, commitWorktree(wt, fmt.Sprintf("Created tag %s", tag.Id)) } // RemoveTag deletes one tag from a calendar repository. From 7df3790f0e4ab4e62f2b44c3276a46cdd9a94a7f Mon Sep 17 00:00:00 2001 From: firu11 Date: Sun, 28 Jun 2026 18:02:13 +0200 Subject: [PATCH 13/21] Merge branch 'main' into metadata --- e2e/events_repeating_test.go | 950 ++++++++++++++++++++++------------- pkg/core/core_events.go | 161 +++--- pkg/core/event.go | 2 +- pkg/core/utils.go | 15 + 4 files changed, 704 insertions(+), 424 deletions(-) diff --git a/e2e/events_repeating_test.go b/e2e/events_repeating_test.go index 1d17a5e..fb235c9 100644 --- a/e2e/events_repeating_test.go +++ b/e2e/events_repeating_test.go @@ -2,9 +2,6 @@ package e2e import ( - "encoding/binary" - "reflect" - "slices" "testing" "time" @@ -14,250 +11,249 @@ import ( const TestCalendarName = "test" -func TestAddInfinitelyRepeatingEventAndGetEvents(t *testing.T) { - c := core.NewCore() - - err := c.CreateCalendar(TestCalendarName, "") - if err != nil { - t.Fatalf("failed to init repo: %v", err) - } +func TestRepeatingEvent_GetEvents_UntilWeekly_GeneratesOccurrencesInRange(t *testing.T) { + c := newTestCore(t) - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) + startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour*4, core.Repetition{ + Frequency: core.Week, + Interval: 1, + Until: startTime.AddDate(1, 0, 0), }) - id := uuid.New() - startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - eventIn := core.Event{ - Id: id, - Calendar: TestCalendarName, - Title: "Repeating Event", - From: startTime, - To: startTime.Add(time.Hour * 4), - Repeat: &core.Repetition{ - Frequency: core.Week, - Interval: 1, - Until: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), - }, + stored := requireEvent(t, c, parent.Id) + if !stored.From.Equal(parent.From) { + t.Fatalf("stored parent From mismatch: expected %s, got %s", parent.From, stored.From) } - _, err = c.CreateEvent(eventIn) - if err != nil { - t.Errorf("failed to create an event: %v", err) + if stored.Repeat == nil { + t.Fatalf("stored parent should be repeating") } - eventOut, err := c.GetEvent(id) - if err != nil { - t.Fatalf("failed to get an event by id: %v", err) + events := c.GetEvents(startTime, startTime.AddDate(1, 0, 0)) + if len(events) != 53 { + t.Fatalf("expected 53 weekly events in 2026, got %d: %+v", len(events), events) } - eventIn.UpdatedAt = eventOut.UpdatedAt +} - if !reflect.DeepEqual(eventIn, *eventOut) { - t.Errorf("events are not the same: \nin: %+v\n!=\nout: %+v", eventIn, *eventOut) - } +func TestRepeatingEvent_GetEvents_CountWeekly_GeneratesExactCount(t *testing.T) { + c := newTestCore(t) - queryTo := startTime.AddDate(1, 0, 0) - eventsOut := c.GetEvents(startTime, queryTo) - if len(eventsOut) != 53 { // 2026 has 53 weeks - t.Errorf("not all events were generated; eventsOut: %d: %+v", len(eventsOut), eventsOut) - } + const count = 6 + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour*4, core.Repetition{ + Frequency: core.Week, + Interval: 1, + Count: count, + }) + + events := c.GetEvents(startTime.Add(-time.Hour), startTime.AddDate(0, 0, count*7+1)) + assertEventStarts(t, events, + startTime, + startTime.AddDate(0, 0, 7), + startTime.AddDate(0, 0, 14), + startTime.AddDate(0, 0, 21), + startTime.AddDate(0, 0, 28), + startTime.AddDate(0, 0, 35), + ) } -func TestAddCountRepeatingEventAndGetEvents(t *testing.T) { - c := core.NewCore() +func TestRepeatingEvent_Remove_Current_AddsParentExceptionAndHidesChild(t *testing.T) { + c := newTestCore(t) - err := c.CreateCalendar(TestCalendarName, "") - if err != nil { - t.Fatalf("failed to init repo: %v", err) - } - - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) + const count = 6 + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: count, }) - const COUNT = 6 - id := uuid.New() - startTime := time.Now() - eventIn := core.Event{ - Id: id, - Calendar: TestCalendarName, - Title: "Repeating Event", - From: startTime, - To: startTime.Add(time.Hour * 4), - Repeat: &core.Repetition{ - Frequency: core.Week, - Interval: 1, - Count: COUNT, - }, - } - _, err = c.CreateEvent(eventIn) - if err != nil { - t.Errorf("failed to create an event: %v", err) - } + events := c.GetEvents(startTime, startTime.AddDate(0, 0, count)) + assertEventStarts(t, events, + startTime, + startTime.AddDate(0, 0, 1), + startTime.AddDate(0, 0, 2), + startTime.AddDate(0, 0, 3), + startTime.AddDate(0, 0, 4), + startTime.AddDate(0, 0, 5), + ) - eventOut, err := c.GetEvent(id) - if err != nil { - t.Fatalf("failed to get an event by id: %v", err) + removed := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) + if err := c.RemoveRepeatingEvent(removed, core.Current); err != nil { + t.Fatalf("failed to remove child event: %v", err) } - eventIn.UpdatedAt = eventOut.UpdatedAt - if !reflect.DeepEqual(eventIn, *eventOut) { - t.Errorf("events are not the same: \nin: %+v\n!=\nout: %+v", eventIn, *eventOut) - } + storedParent := requireEvent(t, c, parent.Id) + assertParentHasException(t, storedParent, removed.Id) - queryFrom := time.Now().AddDate(-1, 0, 0) - queryTo := time.Now().AddDate(1, 0, 0) - eventsOut := c.GetEvents(queryFrom, queryTo) - if len(eventsOut) != COUNT { - t.Errorf("not all events were generated; eventsOut: %d: %+v", len(eventsOut), eventsOut) + events = c.GetEvents(startTime, startTime.AddDate(0, 0, count)) + assertNoEventAt(t, events, removed.From) + if len(events) != count-1 { + t.Fatalf("expected %d events after delete, got %d: %+v", count-1, len(events), events) } } -func TestAddRepeatingEventsAndRemoveRepeatingEvent(t *testing.T) { - c := core.NewCore() +func TestRepeatingEvent_Remove_Current_RejectsParentEvent(t *testing.T) { + c := newTestCore(t) - err := c.CreateCalendar(TestCalendarName, "") - if err != nil { - t.Fatalf("failed to init repo: %v", err) + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: 3, + }) + + err := c.RemoveRepeatingEvent(parent, core.Current) + if err == nil { + t.Fatalf("expected parent delete through RemoveRepeatingEvent to fail") } +} - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) +func TestRepeatingEvent_Update_Current_DetachesOnlyTargetChild(t *testing.T) { + c := newTestCore(t) + + const count = 5 + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + parent := createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: count, }) - const COUNT = 6 - id := uuid.New() - startTime := time.Now() - eventIn := core.Event{ - Id: id, - Calendar: TestCalendarName, - Title: "Repeating Event", - From: startTime, - To: startTime.Add(time.Hour * 4), - Repeat: &core.Repetition{ - Frequency: core.Week, - Interval: 1, - Count: COUNT, - }, - } - _, err = c.CreateEvent(eventIn) - if err != nil { - t.Errorf("failed to create an event: %v", err) - } + events := c.GetEvents(startTime, startTime.AddDate(0, 0, count)) + target := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) - eventOut, err := c.GetEvent(id) + updated := cloneEvent(target) + updated.Title = "Daily event - update" + updated.From = target.From.Add(time.Hour) + updated.To = target.To.Add(time.Hour) + + _, err := c.UpdateRepeatingEvent(target, updated, core.Current) if err != nil { - t.Fatalf("failed to get an event by id: %v", err) + t.Fatalf("failed to update child event with Current strategy: %v", err) } - eventIn.UpdatedAt = eventOut.UpdatedAt - if !reflect.DeepEqual(eventIn, *eventOut) { - t.Errorf("events are not the same: \nin: %+v\n!=\nout: %+v", eventIn, *eventOut) - } + storedParent := requireEvent(t, c, parent.Id) + assertParentHasException(t, storedParent, target.Id) - queryFrom := time.Now().AddDate(-1, 0, 0) - queryTo := time.Now().AddDate(1, 0, 0) - eventsOut := c.GetEvents(queryFrom, queryTo) - if len(eventsOut) != COUNT { - t.Errorf("not all events were generated; eventsOut: %d: %+v", len(eventsOut), eventsOut) - return + events = c.GetEvents(startTime, startTime.AddDate(0, 0, count)) + assertEventStarts(t, events, + startTime, + startTime.AddDate(0, 0, 1), + startTime.AddDate(0, 0, 2).Add(time.Hour), + startTime.AddDate(0, 0, 3), + startTime.AddDate(0, 0, 4), + ) + assertNoEventAt(t, events, target.From) + + detached := requireEventAt(t, events, updated.From) + if detached.Repeat != nil { + t.Fatalf("detached event should not repeat") } - eventToRemove := eventsOut[0] - if err := c.RemoveRepeatingEvent(eventToRemove, core.Current); err != nil { - t.Errorf("failed to remove event: %v", err) + if detached.ParentId != uuid.Nil { + t.Fatalf("detached event should not have parent id, got %s", detached.ParentId) } - - eventsOut = c.GetEvents(queryFrom, queryTo) - if len(eventsOut) != COUNT-1 || slices.Contains(eventsOut, eventToRemove) { - t.Errorf("event wasn't removed correctly; eventsOut: %d: %+v", len(eventsOut), eventsOut) + if detached.Title != updated.Title { + t.Fatalf("detached title mismatch: expected %q, got %q", updated.Title, detached.Title) } } -func TestUpdateRepeatingEvent_Current(t *testing.T) { - c := core.NewCore() +func TestRepeatingEvent_Update_StrategiesRejectParentEvents(t *testing.T) { + c := newTestCore(t) - err := c.CreateCalendar(TestCalendarName, "") - if err != nil { - t.Fatalf("failed to init repo: %v", err) + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: 3, + }) + + tests := []struct { + name string + strategy core.UpdateStrategy + }{ + {name: "Current", strategy: core.Current}, + {name: "Following", strategy: core.Following}, + {name: "All", strategy: core.All}, } - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated := cloneEvent(parent) + updated.Title = "Should fail" + + _, err := c.UpdateRepeatingEvent(parent, updated, tt.strategy) + if err == nil { + t.Fatalf("expected parent update with %s strategy to fail", tt.name) + } + }) + } +} + +func TestRepeatingEvent_Update_Following_SplitsSeriesFromTargetChild(t *testing.T) { + c := newTestCore(t) parentId := uuid.New() startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parentEvent := core.Event{ + parent := core.Event{ Id: parentId, Calendar: TestCalendarName, - Title: "Daily event", + Title: "Daily Meeting", From: startTime, To: startTime.Add(time.Hour), Repeat: &core.Repetition{ Frequency: core.Day, Interval: 1, - Count: 5, + Until: startTime.AddDate(0, 1, 0), }, } - _, _ = c.CreateEvent(parentEvent) - - eventsOut := c.GetEvents(startTime, startTime.AddDate(0, 0, 5)) - if len(eventsOut) != 5 { - t.Fatalf("expected child events, got %d", len(eventsOut)) - } + createEvent(t, c, parent) - targetEvent := eventsOut[2] - updatedTarget := targetEvent - originalFrom := targetEvent.From + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 21)) + target := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) + previous := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) - updatedTarget.Title = "Daily event - update" - updatedTarget.From = startTime.Add(time.Hour) - updatedTarget.To = startTime.Add(2 * time.Hour) + updated := cloneEvent(target) + updated.Title = "Daily Meeting - New Phase" + updated.Repeat = &core.Repetition{ + Frequency: core.Day, + Interval: 1, + Until: startTime.AddDate(0, 1, 0), + } - _, err = c.UpdateRepeatingEvent(targetEvent, updatedTarget, core.Current) + newParent, err := c.UpdateRepeatingEvent(target, updated, core.Following) if err != nil { - t.Errorf("failed to update child event (Current): %v", err) + t.Fatalf("failed to update child event with Following strategy: %v", err) } - parentOut, _ := c.GetEvent(parentId) - foundException := false - for _, ex := range parentOut.Repeat.Exceptions { - t := time.Unix(int64(binary.BigEndian.Uint32(ex[12:16])), 0) - if t.Equal(originalFrom) { - foundException = true - break - } + if newParent.ParentId != uuid.Nil { + t.Fatalf("new event should be a parent, got ParentId %s", newParent.ParentId) } - if !foundException { - t.Errorf("parent event did not receive the exception for time: %s", originalFrom) + if newParent.Title != updated.Title { + t.Fatalf("new parent title mismatch: expected %q, got %q", updated.Title, newParent.Title) + } + if !newParent.From.Equal(target.From) { + t.Fatalf("new parent From mismatch: expected %s, got %s", target.From, newParent.From) } - isolatedOut := c.GetEvents(updatedTarget.From, updatedTarget.To)[0] - if !isolatedOut.From.Equal(startTime.Add(time.Hour)) { - t.Errorf("isolated event doesnt have the right From") + oldParent := requireEvent(t, c, parentId) + if oldParent.Repeat == nil { + t.Fatalf("old parent should still repeat before the split") } - if !isolatedOut.To.Equal(startTime.Add(2 * time.Hour)) { - t.Errorf("isolated event doesnt have the right To") + if !oldParent.Repeat.Until.Equal(previous.From) { + t.Fatalf("old parent Until mismatch: expected %s, got %s", previous.From, oldParent.Repeat.Until) } - if isolatedOut.Repeat != nil { - t.Errorf("isolated event should not have a repeat struct") + if oldParent.Repeat.Count != 0 { + t.Fatalf("old parent Count should be reset to 0, got %d", oldParent.Repeat.Count) } } -func TestUpdateRepeatingEvent_Following(t *testing.T) { - c := core.NewCore() - - err := c.CreateCalendar(TestCalendarName, "") - if err != nil { - t.Fatalf("failed to init repo: %v", err) - } - - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) - }) +func TestRepeatingEvent_Update_Following_SecondChild_DoesNotLeaveInvalidOldParent(t *testing.T) { + c := newTestCore(t) parentId := uuid.New() startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parentEvent := core.Event{ + parent := core.Event{ Id: parentId, Calendar: TestCalendarName, Title: "Daily Meeting", @@ -269,231 +265,407 @@ func TestUpdateRepeatingEvent_Following(t *testing.T) { Until: startTime.AddDate(0, 1, 0), }, } - _, _ = c.CreateEvent(parentEvent) + createEvent(t, c, parent) - eventsOut := c.GetEvents(startTime, startTime.AddDate(0, 0, 21)) - targetEvent := eventsOut[2] - updatedTarget := targetEvent - expectedUntilCap := eventsOut[1].From + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 21)) + target := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) - updatedTarget.Title = "Weekly Meeting - New Phase" - updatedTarget.Repeat = &core.Repetition{ + updated := cloneEvent(target) + updated.Title = "Daily Meeting - New Phase" + updated.Repeat = &core.Repetition{ Frequency: core.Day, Interval: 1, Until: startTime.AddDate(0, 1, 0), } - newParentOut, err := c.UpdateRepeatingEvent(targetEvent, updatedTarget, core.Following) + + newParent, err := c.UpdateRepeatingEvent(target, updated, core.Following) if err != nil { - t.Errorf("failed to update child event (Following): %v", err) + t.Fatalf("failed to update second child with Following strategy: %v", err) } - if newParentOut.ParentId != uuid.Nil { - t.Errorf("new event should be a parent, but ParentId is %s", newParentOut.ParentId) + if newParent.ParentId != uuid.Nil { + t.Fatalf("new event should be a parent, got ParentId %s", newParent.ParentId) } - if newParentOut.Title != "Weekly Meeting - New Phase" { - t.Errorf("title not updated on new parent") + + oldParent := requireEvent(t, c, parentId) + if oldParent.Repeat != nil && !oldParent.Repeat.Until.After(oldParent.From) { + t.Fatalf("old parent has invalid repetition boundary: From=%s Until=%s", oldParent.From, oldParent.Repeat.Until) } +} - olderParentOut, err := c.GetEvent(parentId) - if err != nil { - t.Fatalf("failed to get parent out: %v", err) +func TestRepeatingEvent_Update_Following_CarriesAndShiftsFutureExceptions(t *testing.T) { + c := newTestCore(t) + + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Until: startTime.AddDate(0, 0, 10), + }) + + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 6)) + second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + fourth := requireEventAt(t, events, startTime.AddDate(0, 0, 3)) + + if err := c.RemoveRepeatingEvent(fourth, core.Current); err != nil { + t.Fatalf("failed to remove fourth child: %v", err) } - if !olderParentOut.Repeat.Until.Equal(expectedUntilCap) { - t.Errorf("parent event Until was not capped correctly. Expected %s, got %s", expectedUntilCap, olderParentOut.Repeat.Until) + + shift := time.Hour + updatedSecond := cloneEvent(second) + updatedSecond.From = second.From.Add(shift) + updatedSecond.To = second.To.Add(shift) + updatedSecond.Repeat = &core.Repetition{ + Frequency: core.Day, + Interval: 1, + Until: startTime.AddDate(0, 0, 10), } - if olderParentOut.Repeat.Count != 0 { - t.Errorf("parent event Count should be overridden to 0, got %d", olderParentOut.Repeat.Count) + + _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) + if err != nil { + t.Fatalf("failed to update second child with Following strategy: %v", err) } + + events = c.GetEvents(startTime, startTime.AddDate(0, 0, 5)) + assertEventStarts(t, events, + startTime, + startTime.AddDate(0, 0, 1).Add(shift), + startTime.AddDate(0, 0, 2).Add(shift), + startTime.AddDate(0, 0, 4).Add(shift), + ) + assertNoEventAt(t, events, startTime.AddDate(0, 0, 3).Add(shift)) } -func TestUpdateRepeatingEvent_Following_SecondOccurrence(t *testing.T) { - c := core.NewCore() +func TestRepeatingEvent_Update_Following_FirstChild_ShiftBackDoesNotKeepOriginalParent(t *testing.T) { + c := newTestCore(t) + + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: 3, + }) + + events := c.GetEvents(startTime.Add(-time.Hour), startTime.AddDate(0, 0, 3)) + first := requireEventAt(t, events, startTime) - err := c.CreateCalendar(TestCalendarName, "") + shift := -2 * time.Hour + updatedFirst := cloneEvent(first) + updatedFirst.From = first.From.Add(shift) + updatedFirst.To = first.To.Add(shift) + + _, err := c.UpdateRepeatingEvent(first, updatedFirst, core.Following) if err != nil { - t.Fatalf("failed to init repo: %v", err) + t.Fatalf("failed to update first child with Following strategy: %v", err) } - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) - }) + events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 0, 3)) + assertEventStarts(t, events, + startTime.Add(shift), + startTime.AddDate(0, 0, 1).Add(shift), + startTime.AddDate(0, 0, 2).Add(shift), + ) + assertNoEventAt(t, events, startTime) +} + +func TestRepeatingEvent_Update_Following_TitleOnlyKeepsFutureDeletedChildHidden(t *testing.T) { + c := newTestCore(t) - parentId := uuid.New() startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parentEvent := core.Event{ - Id: parentId, - Calendar: TestCalendarName, - Title: "Daily Meeting", - From: startTime, - To: startTime.Add(time.Hour), - Repeat: &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 1, 0), - }, - } + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Until: startTime.AddDate(0, 0, 10), + }) - _, err = c.CreateEvent(parentEvent) - if err != nil { - t.Fatalf("failed to create parent event: %v", err) - } + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 6)) + second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + fourth := requireEventAt(t, events, startTime.AddDate(0, 0, 3)) - eventsOut := c.GetEvents(startTime, startTime.AddDate(0, 0, 21)) - if len(eventsOut) < 2 { - t.Fatalf("expected at least 2 expanded events, got %d", len(eventsOut)) + if err := c.RemoveRepeatingEvent(fourth, core.Current); err != nil { + t.Fatalf("failed to remove fourth child: %v", err) } - targetEvent := eventsOut[1] // second occurrence - - updatedTarget := targetEvent - updatedTarget.Title = "Daily Meeting - New Phase" - updatedTarget.Repeat = &core.Repetition{ + updatedSecond := cloneEvent(second) + updatedSecond.Title = "Daily Standup - New Phase" + updatedSecond.Repeat = &core.Repetition{ Frequency: core.Day, Interval: 1, - Until: startTime.AddDate(0, 1, 0), + Until: startTime.AddDate(0, 0, 10), } - newParentOut, err := c.UpdateRepeatingEvent(targetEvent, updatedTarget, core.Following) + _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) if err != nil { - t.Fatalf("failed to update child event Following: %v", err) + t.Fatalf("failed to update second child with Following strategy: %v", err) } - if newParentOut.ParentId != uuid.Nil { - t.Errorf("new event should be a parent, but ParentId is %s", newParentOut.ParentId) + events = c.GetEvents(startTime, startTime.AddDate(0, 0, 5)) + assertEventStarts(t, events, + startTime, + startTime.AddDate(0, 0, 1), + startTime.AddDate(0, 0, 2), + startTime.AddDate(0, 0, 4), + ) + assertNoEventAt(t, events, startTime.AddDate(0, 0, 3)) +} + +func TestRepeatingEvent_Update_Following_SplitsExceptionsAtOriginalTargetTime(t *testing.T) { + c := newTestCore(t) + + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Until: startTime.AddDate(0, 0, 10), + }) + + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 6)) + second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + third := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) + + if err := c.RemoveRepeatingEvent(third, core.Current); err != nil { + t.Fatalf("failed to remove third child: %v", err) } - if newParentOut.Title != "Daily Meeting - New Phase" { - t.Errorf("title not updated on new parent") + shift := 72 * time.Hour + updatedSecond := cloneEvent(second) + updatedSecond.From = second.From.Add(shift) + updatedSecond.To = second.To.Add(shift) + updatedSecond.Repeat = &core.Repetition{ + Frequency: core.Day, + Interval: 1, + Until: startTime.AddDate(0, 0, 10), } - olderParentOut, err := c.GetEvent(parentId) + _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) if err != nil { - t.Fatalf("failed to get old parent: %v", err) + t.Fatalf("failed to update second child with Following strategy: %v", err) } - if olderParentOut.Repeat != nil && !olderParentOut.Repeat.Until.After(olderParentOut.From) { - t.Fatalf( - "old parent has invalid repetition boundary: \nFrom=%s\nUntil=%s", - olderParentOut.From, - olderParentOut.Repeat.Until, - ) - } + events = c.GetEvents(startTime, startTime.AddDate(0, 0, 11)) + requireEventAt(t, events, startTime) + requireEventAt(t, events, second.From.Add(shift)) + requireEventAt(t, events, startTime.AddDate(0, 0, 3).Add(shift)) + assertNoEventAt(t, events, third.From.Add(shift)) } -func TestUpdateRepeatingEvent_All(t *testing.T) { - c := core.NewCore() +func TestRepeatingEvent_Update_Following_CountSeriesKeepsRemainingSlotsAfterFutureDeletedChild(t *testing.T) { + c := newTestCore(t) - err := c.CreateCalendar(TestCalendarName, "") - if err != nil { - t.Fatalf("failed to init repo: %v", err) + const count = 5 + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: count, + }) + + events := c.GetEvents(startTime, startTime.AddDate(0, 0, count+1)) + second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + fourth := requireEventAt(t, events, startTime.AddDate(0, 0, 3)) + + if err := c.RemoveRepeatingEvent(fourth, core.Current); err != nil { + t.Fatalf("failed to remove fourth child: %v", err) } - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) - }) + shift := time.Hour + updatedSecond := cloneEvent(second) + updatedSecond.From = second.From.Add(shift) + updatedSecond.To = second.To.Add(shift) + updatedSecond.Repeat = &core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: count, + } - parentId := uuid.New() - startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parentEvent := core.Event{ - Id: parentId, - Calendar: TestCalendarName, - Title: "Monthly Review", - From: startTime, - To: startTime.Add(time.Hour), - Repeat: &core.Repetition{ - Frequency: core.Month, - Interval: 1, - Count: 5, - }, + _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) + if err != nil { + t.Fatalf("failed to update second child with Following strategy: %v", err) } - _, _ = c.CreateEvent(parentEvent) + events = c.GetEvents(startTime, startTime.AddDate(0, 0, count+1)) + assertEventStarts(t, events, + startTime, + startTime.AddDate(0, 0, 1).Add(shift), + startTime.AddDate(0, 0, 2).Add(shift), + startTime.AddDate(0, 0, 4).Add(shift), + ) + assertNoEventAt(t, events, startTime.AddDate(0, 0, 3).Add(shift)) +} - eventsOut := c.GetEvents(startTime, startTime.AddDate(0, 6, 0)) - targetEvent, _ := c.GetEvent(eventsOut[0].ParentId) +func TestRepeatingEvent_Update_All_ShiftsWholeSeriesFromChild(t *testing.T) { + c := newTestCore(t) - shift := 2 * time.Hour - targetEvent.From = targetEvent.From.Add(shift) - targetEvent.To = targetEvent.To.Add(shift) - targetEvent.Title = "Monthly Review - Shifted" - targetEvent.Repeat = &core.Repetition{ + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + parent := createRepeatingEvent(t, c, "Monthly Review", startTime, time.Hour, core.Repetition{ Frequency: core.Month, Interval: 1, Count: 5, - } + }) + + events := c.GetEvents(startTime, startTime.AddDate(0, 6, 0)) + first := requireEventAt(t, events, startTime) - _, err = c.UpdateEvent(*targetEvent) + shift := 2 * time.Hour + updated := cloneEvent(first) + updated.From = first.From.Add(shift) + updated.To = first.To.Add(shift) + updated.Title = "Monthly Review - Shifted" + + _, err := c.UpdateRepeatingEvent(first, updated, core.All) if err != nil { - t.Errorf("failed to update child event (All): %v", err) + t.Fatalf("failed to update child event with All strategy: %v", err) } - parentOut, _ := c.GetEvent(parentId) - expectedNewFrom := startTime.Add(shift) - if !parentOut.From.Equal(expectedNewFrom) { - t.Errorf("parent event From was not shifted. Expected %s, got %s", expectedNewFrom, parentOut.From) + storedParent := requireEvent(t, c, parent.Id) + if !storedParent.From.Equal(startTime.Add(shift)) { + t.Fatalf("parent From mismatch: expected %s, got %s", startTime.Add(shift), storedParent.From) } - if parentOut.Title != "Monthly Review - Shifted" { - t.Errorf("parent event Title was not updated") + if storedParent.Title != updated.Title { + t.Fatalf("parent title mismatch: expected %q, got %q", updated.Title, storedParent.Title) } + + events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 6, 0).Add(shift)) + assertEventStarts(t, events, + startTime.Add(shift), + startTime.AddDate(0, 1, 0).Add(shift), + startTime.AddDate(0, 2, 0).Add(shift), + startTime.AddDate(0, 3, 0).Add(shift), + startTime.AddDate(0, 4, 0).Add(shift), + ) } -func TestUpdateEvent_FromStandardToRepeating(t *testing.T) { - c := core.NewCore() +func TestRepeatingEvent_Update_All_ShiftsExceptionsAndKeepsDeletedChildHidden(t *testing.T) { + c := newTestCore(t) + + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Repeating", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: 3, + }) - err := c.CreateCalendar(TestCalendarName, "") + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 3)) + middle := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) + + if err := c.RemoveRepeatingEvent(middle, core.Current); err != nil { + t.Fatalf("failed to remove middle child: %v", err) + } + + events = c.GetEvents(startTime, startTime.AddDate(0, 0, 3)) + first := requireEventAt(t, events, startTime) + + shift := -time.Hour + updated := cloneEvent(first) + updated.From = first.From.Add(shift) + updated.To = first.To.Add(shift) + updated.Title = "Repeating - Shifted" + + _, err := c.UpdateRepeatingEvent(first, updated, core.All) if err != nil { - t.Fatalf("failed to init repo: %v", err) + t.Fatalf("failed to update child event with All strategy: %v", err) } - t.Cleanup(func() { - _ = c.RemoveCalendar(TestCalendarName) + events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 0, 3)) + assertEventStarts(t, events, + startTime.Add(shift), + startTime.AddDate(0, 0, 2).Add(shift), + ) + assertNoEventAt(t, events, startTime.AddDate(0, 0, 1).Add(shift)) +} + +func TestRepeatingEvent_Update_All_RepeatRuleChangeKeepsStoredExceptions(t *testing.T) { + c := newTestCore(t) + + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Repeating", startTime, time.Hour, core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: 3, }) + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 3)) + middle := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) + + if err := c.RemoveRepeatingEvent(middle, core.Current); err != nil { + t.Fatalf("failed to remove middle child: %v", err) + } + + events = c.GetEvents(startTime, startTime.AddDate(0, 0, 3)) + first := requireEventAt(t, events, startTime) + + shift := -time.Hour + updated := cloneEvent(first) + updated.From = first.From.Add(shift) + updated.To = first.To.Add(shift) + updated.Title = "Repeating - Shifted" + updated.Repeat = &core.Repetition{ + Frequency: core.Day, + Interval: 1, + Count: 4, + Exceptions: []uuid.UUID{}, + } + + _, err := c.UpdateRepeatingEvent(first, updated, core.All) + if err != nil { + t.Fatalf("failed to update child event with All strategy: %v", err) + } + + events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 0, 5)) + assertEventStarts(t, events, + startTime.Add(shift), + startTime.AddDate(0, 0, 2).Add(shift), + startTime.AddDate(0, 0, 3).Add(shift), + ) + assertNoEventAt(t, events, startTime.AddDate(0, 0, 1).Add(shift)) +} + +func TestEvent_Update_StandardToRepeating_GeneratesChildren(t *testing.T) { + c := newTestCore(t) + startTime := time.Date(2026, 5, 5, 15, 0, 0, 0, time.UTC) - eventIn := core.Event{ + event := core.Event{ Id: uuid.New(), Calendar: TestCalendarName, Title: "One-time meeting", From: startTime, To: startTime.Add(time.Hour), } + createEvent(t, c, event) - _, err = c.CreateEvent(eventIn) - if err != nil { - t.Fatalf("failed to create an event: %v", err) - } - - // now update it to be a repeating event - eventIn.Title = "Weekly meeting" - eventIn.Repeat = &core.Repetition{ + updated := event + updated.Title = "Weekly meeting" + updated.Repeat = &core.Repetition{ Frequency: core.Week, Interval: 1, Count: 3, } - _, err = c.UpdateEvent(eventIn) + _, err := c.UpdateEvent(updated) if err != nil { - t.Fatalf("failed to update event to repeating: %v", err) + t.Fatalf("failed to update standard event to repeating: %v", err) } - eventsOut := c.GetEvents(startTime, startTime.AddDate(0, 1, 0)) - if len(eventsOut) != 3 { - t.Errorf("expected 3 events after update, got %d", len(eventsOut)) - } + events := c.GetEvents(startTime, startTime.AddDate(0, 1, 0)) + assertEventStarts(t, events, + startTime, + startTime.AddDate(0, 0, 7), + startTime.AddDate(0, 0, 14), + ) - updatedParent, err := c.GetEvent(eventIn.Id) - if err != nil { - t.Fatalf("failed to get parent event after update: %v", err) + stored := requireEvent(t, c, event.Id) + if stored.Repeat == nil { + t.Fatalf("updated parent should repeat") } - if updatedParent.Repeat == nil || updatedParent.Repeat.Count != 3 { - t.Errorf("parent event was not correctly updated to be repeating") + if stored.Repeat.Count != 3 { + t.Fatalf("updated parent Count mismatch: expected 3, got %d", stored.Repeat.Count) } } -func TestUpdateFollowing_ExceptionCarriedToNewParent(t *testing.T) { - c := core.NewCore() +func newTestCore(t *testing.T) *core.Core { + t.Helper() - err := c.CreateCalendar(TestCalendarName, "") - if err != nil { + c := core.NewCore() + if err := c.CreateCalendar(TestCalendarName, ""); err != nil { t.Fatalf("failed to init repo: %v", err) } @@ -501,72 +673,128 @@ func TestUpdateFollowing_ExceptionCarriedToNewParent(t *testing.T) { _ = c.RemoveCalendar(TestCalendarName) }) - const count = 5 - startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parentEvent := core.Event{ + return c +} + +func createRepeatingEvent(t *testing.T, c *core.Core, title string, from time.Time, duration time.Duration, repeat core.Repetition) core.Event { + t.Helper() + + event := core.Event{ Id: uuid.New(), Calendar: TestCalendarName, - Title: "Daily Standup", - From: startTime, - To: startTime.Add(time.Hour), - Repeat: &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }, + Title: title, + From: from, + To: from.Add(duration), + Repeat: &repeat, } - _, err = c.CreateEvent(parentEvent) + createEvent(t, c, event) + + return event +} + +func createEvent(t *testing.T, c *core.Core, event core.Event) core.Event { + t.Helper() + + created, err := c.CreateEvent(event) if err != nil { t.Fatalf("failed to create event: %v", err) } - allEvents := c.GetEvents(startTime, startTime.AddDate(0, 0, count+5)) - if len(allEvents) != count { - t.Fatalf("setup: expected %d events, got %d", count, len(allEvents)) + return *created +} + +func requireEvent(t *testing.T, c *core.Core, id uuid.UUID) core.Event { + t.Helper() + + event, err := c.GetEvent(id) + if err != nil { + t.Fatalf("failed to get event %s: %v", id, err) } - // delete the 4th child - creates an exception on the parent - if err := c.RemoveRepeatingEvent(allEvents[3], core.Current); err != nil { - t.Fatalf("failed to remove 4th event: %v", err) + return *event +} + +func requireEventAt(t *testing.T, events []core.Event, from time.Time) core.Event { + t.Helper() + + event, ok := findEventByFrom(events, from) + if !ok { + t.Fatalf("expected event at %s, got starts %v", from, eventStarts(events)) } - // update the 2nd child (and all following) by shifting it 1h - const shift = time.Hour - secondEvent := allEvents[1] - updatedSecond := secondEvent - updatedSecond.From = secondEvent.From.Add(shift) - updatedSecond.To = secondEvent.To.Add(shift) - updatedSecond.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, + return event +} + +func assertEventStarts(t *testing.T, events []core.Event, wants ...time.Time) { + t.Helper() + + if len(events) != len(wants) { + t.Fatalf("expected %d events, got %d: %v", len(wants), len(events), eventStarts(events)) } - _, err = c.UpdateRepeatingEvent(secondEvent, updatedSecond, core.Following) - if err != nil { - t.Fatalf("failed to Following-update second event: %v", err) + for _, want := range wants { + if _, ok := findEventByFrom(events, want); !ok { + t.Errorf("missing event at %s; got starts %v", want, eventStarts(events)) + } + } +} + +func assertNoEventAt(t *testing.T, events []core.Event, from time.Time) { + t.Helper() + + if event, ok := findEventByFrom(events, from); ok { + t.Fatalf("expected no event at %s, got %+v", from, event) } +} + +func assertParentHasException(t *testing.T, parent core.Event, exception uuid.UUID) { + t.Helper() - result := c.GetEvents(startTime, startTime.AddDate(0, 0, count+5)) - if len(result) != count-1 { - t.Fatalf("expected %d events after delete+split, got %d: %+v", count-1, len(result), result) + if parent.Repeat == nil { + t.Fatalf("parent %s should be repeating", parent.Id) } + if !containsUUID(parent.Repeat.Exceptions, exception) { + t.Fatalf("parent %s does not contain exception %s; exceptions: %v", parent.Id, exception, parent.Repeat.Exceptions) + } +} + +func cloneEvent(event core.Event) core.Event { + cloned := event - type expected struct { - from time.Time - desc string + if event.Repeat != nil { + repeat := *event.Repeat + repeat.Exceptions = append([]uuid.UUID(nil), event.Repeat.Exceptions...) + cloned.Repeat = &repeat } - wants := []expected{ - {startTime, "day 0 unshifted (original parent, capped exclusively)"}, - {startTime.AddDate(0, 0, 1).Add(shift), "day 1 shifted"}, - {startTime.AddDate(0, 0, 2).Add(shift), "day 2 shifted"}, - // day 3 gap - exception should carry and time-adjust - {startTime.AddDate(0, 0, 4).Add(shift), "day 4 shifted"}, + + return cloned +} + +func findEventByFrom(events []core.Event, from time.Time) (core.Event, bool) { + for _, event := range events { + if event.From.Equal(from) { + return event, true + } } - for i, w := range wants { - if !result[i].From.Equal(w.from) { - t.Errorf("result[%d] (%s): expected From %s, got %s", i, w.desc, w.from, result[i].From) + return core.Event{}, false +} + +func containsUUID(ids []uuid.UUID, id uuid.UUID) bool { + for _, cur := range ids { + if cur == id { + return true } } + + return false +} + +func eventStarts(events []core.Event) []time.Time { + starts := make([]time.Time, 0, len(events)) + for _, event := range events { + starts = append(starts, event.From) + } + + return starts } diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index f4f604e..e2e0323 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "path" - "reflect" "slices" "time" @@ -116,6 +115,13 @@ 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 { + return nil, fmt.Errorf("repeating update requires child events") + } + if old.ParentId != new.ParentId { + return nil, fmt.Errorf("old and new child events have different parent ids") + } + cal, ok := c.calendars[new.Calendar] if !ok { return nil, fmt.Errorf("the specified calendar is missing") @@ -268,7 +274,6 @@ func (c *Core) updateCurrentChild(updated *Event) (*Event, error) { if !ok || parent == nil || !parent.IsParent() { return nil, fmt.Errorf("no valid parent found") } - if parent.Repeat == nil { return nil, fmt.Errorf("parent is not a repeating event, WTF") } @@ -288,92 +293,115 @@ func (c *Core) updateCurrentChild(updated *Event) (*Event, error) { return c.CreateEvent(detachedEvent) // save as new } -// Splits the time series into two by stopping the original parent event from repeating further and creating brand new parent with updated properties. +// 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[new.ParentId] + parent, ok := c.events[old.ParentId] if !ok || parent == nil || !parent.IsParent() { return nil, fmt.Errorf("no valid parent found") } + if parent.Repeat == nil { + return nil, fmt.Errorf("parent is not a repeating event") + } - // save originals for rollback + // keep originals for rollback originalRepeat := *parent.Repeat - originalExceptions := append([]uuid.UUID{}, parent.Repeat.Exceptions...) // deep copy + originalExceptions := append([]uuid.UUID(nil), parent.Repeat.Exceptions...) + + // find position in the series + _, splitIndex := firstOccurrenceAtOrAfter(old.From, parent) + if splitIndex == -1 { + return nil, fmt.Errorf("could not find occurrence at or after %s", old.From) + } - until := addUnit(old.From, -1, old.Repeat.Frequency) // cap parent at start of change - if !until.After(parent.From) { + fromDiff := new.From.Sub(old.From) + + // editing the first occurrence -> update entire series + if splitIndex == 0 { + return c.updateAllChildren(old, new) + } + + // split exceptions before and after the edited occurrence + exBefore, exAfter := splitExceptions(originalExceptions, old.From) + + // remove parent before we mutate it + if err := c.intervalTree.RemoveEvent(*parent); err != nil { + return nil, fmt.Errorf("failed to remove parent from interval tree: %w", err) + } + + // ------------ cap the original parent ------------ + previousStart := addUnit(old.From, -originalRepeat.Interval, originalRepeat.Frequency) + if !previousStart.After(parent.From) { + // only one occurrence left -> turn it into a basic (non-repeating) event parent.Repeat = nil } else { - parent.Repeat.Until = until - parent.Repeat.Count = 0 // enforce Until logic over Count + // keep repeating but stop before the split point + capped := originalRepeat + capped.Until = previousStart + capped.Count = 0 + capped.Exceptions = exBefore + parent.Repeat = &capped } - // split exceptions - exBefore, exAfter := splitExceptions(originalExceptions, new.From) - if parent.Repeat != nil { - parent.Repeat.Exceptions = exBefore + // save the capped parent + if err := c.intervalTree.InsertEvent(*parent); err != nil { + parent.Repeat = &originalRepeat + parent.Repeat.Exceptions = originalExceptions + return nil, fmt.Errorf("failed to reinsert capped parent: %w", err) } if err := c.saveAndCommitEvent(parent, fmt.Sprintf("Capped parent event %q", parent.Id)); err != nil { - return nil, fmt.Errorf("failed to commit parent event: %w", err) - } - - // create the new parent for the second half of the time series - newEvent := *new // shallow copy - newEvent.Id = uuid.New() // assign new id - newEvent.ParentId = uuid.Nil // not child anymore - - if originalRepeat.Count != 0 && newEvent.Repeat != nil { - // shorten the repeat for the second half - _, elapsed := firstOccurrenceAtOrAfter(old.From, parent) - if elapsed <= 0 { - // the split is at the first occurance -> nothing to subtract (basically update all) - newEvent.Repeat.Count = originalRepeat.Count - } else { - remaining := originalRepeat.Count - elapsed - if remaining <= 0 { + return nil, fmt.Errorf("failed to commit capped parent: %w", err) + } + + // ------------ create the new continuing series ------------ + newEvent := *new + newEvent.Id = uuid.New() + newEvent.ParentId = uuid.Nil // this is now its own parent + + if newEvent.Repeat != nil { + repeat := *newEvent.Repeat + repeat.Exceptions = nil + + // adjust count for remaining occurrences + if originalRepeat.Count != 0 { + remaining := originalRepeat.Count - splitIndex + if remaining < 1 { remaining = 1 } - newEvent.Repeat.Count = remaining + repeat.Count = remaining } - } - // transform exceptions - fromDiff := newEvent.From.Sub(old.From) - if fromDiff != 0 { - for _, exc := range exAfter { - t := getTimeFromUUID(exc) - t = t.Add(fromDiff) - newEvent.Repeat.Exceptions = append(newEvent.Repeat.Exceptions, generateCustomUUID(newEvent.Id, t)) - } - } - if newEvent.Repeat != nil && newEvent.Repeat.Count != 0 { - newEvent.Repeat.Count -= len(exAfter) - if newEvent.Repeat.Count < 1 { - newEvent.Repeat.Count = 1 + // shift exceptions to the new parent + for _, ex := range exAfter { + exTime := getTimeFromUUID(ex).Add(fromDiff) + repeat.Exceptions = append(repeat.Exceptions, generateCustomUUID(newEvent.Id, exTime)) } + + newEvent.Repeat = &repeat } - createdEvent, err := c.CreateEvent(newEvent) + created, err := c.CreateEvent(newEvent) if err != nil { - // rollback the parent cap + // rollback: restore original parent + _ = c.intervalTree.RemoveEvent(*parent) parent.Repeat = &originalRepeat parent.Repeat.Exceptions = originalExceptions - if rbErr := c.saveAndCommitEvent(parent, fmt.Sprintf("Rolled back cap on parent event %q", parent.Id)); rbErr != nil { - return nil, fmt.Errorf("failed to create new event: %w; rollback also failed: %v", err, rbErr) + + if rbErr := c.intervalTree.InsertEvent(*parent); rbErr != nil { + return nil, fmt.Errorf("failed to create new series: %w; rollback failed too: %v", err, rbErr) + } + if rbErr := c.saveAndCommitEvent(parent, fmt.Sprintf("rolled back parent %q", parent.Id)); rbErr != nil { + return nil, fmt.Errorf("failed to create new series: %w; rollback failed too: %v", err, rbErr) } - return nil, fmt.Errorf("failed to create new event: %w", err) + return nil, fmt.Errorf("failed to create new continuing series: %w", err) } - return createdEvent, nil + return created, nil } // 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) { - if old.IsParent() || new.IsParent() { - return nil, fmt.Errorf("updateRepeatingAll works with child events") - } - parent, ok := c.events[old.ParentId] if !ok || parent == nil || !parent.IsParent() { return nil, fmt.Errorf("no valid parent found") @@ -384,7 +412,7 @@ func (c *Core) updateAllChildren(old, new *Event) (*Event, error) { fromChanged := fromDiff != 0 toChanged := toDiff != 0 - repeatChanged := !reflect.DeepEqual(old.Repeat, new.Repeat) + repeatChanged := repeatRuleChanged(parent.Repeat, new.Repeat) needsReindex := fromChanged || toChanged || repeatChanged @@ -394,15 +422,24 @@ func (c *Core) updateAllChildren(old, new *Event) (*Event, error) { } } + var exceptions []uuid.UUID + if parent.Repeat != nil { + exceptions = append(exceptions, parent.Repeat.Exceptions...) // copy + } + // shift all exceptions by the time fromDiff - if fromChanged && parent.Repeat != nil { - for i := range parent.Repeat.Exceptions { - parent.Repeat.Exceptions[i] = getShiftedUUID(parent.Repeat.Exceptions[i], fromDiff) + if fromChanged { + for i := range exceptions { + exceptions[i] = getShiftedUUID(exceptions[i], fromDiff) } } - if repeatChanged { - parent.Repeat = new.Repeat + if new.Repeat != nil { + repeat := *new.Repeat + repeat.Exceptions = exceptions + parent.Repeat = &repeat + } else { + parent.Repeat = nil } parent.Title = new.Title diff --git a/pkg/core/event.go b/pkg/core/event.go index 9488aae..18c87f3 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -25,7 +25,7 @@ type Event struct { TagId uuid.UUID `json:"tag_id"` ParentId uuid.UUID `json:"parent_id"` // Specific for child events. It is uuid.Nil if the event is basic or parent. Repeat *Repetition `json:"repeat"` - UpdatedAt time.Time `json:"updated_at"` // Used for git conflict resolution; latest wins. + UpdatedAt time.Time `json:"-"` // Used for git conflict resolution; latest wins. Client doesn't need to see this. } // Repetition defines the recurrence rules for a Parent event. diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 9d45052..064a6e2 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "path" + "reflect" "strings" "time" @@ -227,3 +228,17 @@ func splitExceptions(exceptions []uuid.UUID, cutoff time.Time) (before, after [] } return } + +func repeatRuleChanged(a, b *Repetition) bool { + if a == nil || b == nil { + return a != b + } + + aa := *a + bb := *b + + aa.Exceptions = nil + bb.Exceptions = nil + + return !reflect.DeepEqual(aa, bb) +} From 4b1a8e806abb425d65336039415b4c9d66f48054 Mon Sep 17 00:00:00 2001 From: firu11 Date: Mon, 29 Jun 2026 11:58:56 +0200 Subject: [PATCH 14/21] uuid.UUID nil-able --- e2e/events_repeating_test.go | 6 +++--- pkg/api/api.go | 2 +- pkg/core/core_events.go | 26 +++++++++++++++----------- pkg/core/event.go | 12 ++++++------ pkg/core/event_file.go | 4 ++-- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/e2e/events_repeating_test.go b/e2e/events_repeating_test.go index fb235c9..22443a4 100644 --- a/e2e/events_repeating_test.go +++ b/e2e/events_repeating_test.go @@ -150,7 +150,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 && *detached.ParentId != uuid.Nil { t.Fatalf("detached event should not have parent id, got %s", detached.ParentId) } if detached.Title != updated.Title { @@ -226,7 +226,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 && *newParent.ParentId != uuid.Nil { t.Fatalf("new event should be a parent, got ParentId %s", newParent.ParentId) } if newParent.Title != updated.Title { @@ -282,7 +282,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 && *newParent.ParentId != uuid.Nil { t.Fatalf("new event should be a parent, got ParentId %s", newParent.ParentId) } diff --git a/pkg/api/api.go b/pkg/api/api.go index 7ece625..8ef9025 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -177,7 +177,7 @@ func (a *Api) CreateTag(calendar, tagJson string) (string, error) { err := json.Unmarshal([]byte(tagJson), &tag) if err != nil { fmt.Println("CalendarCore got: ", tagJson) - return emptyJson, fmt.Errorf("failed to unmarshal event data: %w", err) + return emptyJson, fmt.Errorf("failed to unmarshal tag data: %w", err) } newTag, err := a.inner.CreateTag(calendar, tag) diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index e2e0323..98c8b1a 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -115,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 { @@ -177,6 +177,10 @@ func (c *Core) RemoveRepeatingEvent(event Event, strat UpdateStrategy) error { 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) @@ -250,7 +254,7 @@ func (c *Core) GetEvents(from, to time.Time) []Event { To: firstStart.Add(eventDuration), Calendar: curEvent.Calendar, TagId: curEvent.TagId, - ParentId: curEvent.Id, + ParentId: &curEvent.Id, Repeat: curEvent.Repeat, } // ignore exceptions @@ -270,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") } @@ -285,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 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") } @@ -356,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 @@ -402,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") } @@ -466,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/event.go b/pkg/core/event.go index 18c87f3..a2b9ad4 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -21,9 +21,9 @@ type Event struct { Description string `json:"description"` From time.Time `json:"from"` To time.Time `json:"to"` - Calendar string `json:"calendar"` // The name of the calendar the event belongs to. - TagId uuid.UUID `json:"tag_id"` - ParentId uuid.UUID `json:"parent_id"` // Specific for child events. It is uuid.Nil if the event is basic or parent. + Calendar string `json:"calendar"` // The name of the calendar the event belongs to. + 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 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. } @@ -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 && *e.ParentId != uuid.Nil } func (e Event) IsParent() bool { - return e.ParentId == uuid.Nil && e.Repeat != nil + return (e.ParentId == nil || *e.ParentId == uuid.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 678517b..7fc4248 100644 --- a/pkg/core/event_file.go +++ b/pkg/core/event_file.go @@ -21,8 +21,8 @@ type eventInFile struct { Description string `json:"description,omitzero"` From time.Time `json:"from,omitzero"` To time.Time `json:"to,omitzero"` - TagId uuid.UUID `json:"tag_id,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"` } From c5420a7f7b4d99c14698a781cd1bcdef4bf489a1 Mon Sep 17 00:00:00 2001 From: firu11 Date: Mon, 29 Jun 2026 11:59:01 +0200 Subject: [PATCH 15/21] sort tags --- pkg/core/core_calendars.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index 1f1259c..789af58 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -64,6 +64,10 @@ 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), From a4d7d460fa232a5fae9cac9204125561aa04a323 Mon Sep 17 00:00:00 2001 From: firu11 Date: Mon, 29 Jun 2026 16:22:08 +0200 Subject: [PATCH 16/21] add UpdateTag --- cmd/wasm/main.go | 5 +++++ pkg/api/api.go | 22 ++++++++++++++++++++++ pkg/core/core_tags.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index fb65779..fa1fcc8 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -95,6 +95,11 @@ func RegisterCallbacks(api *api.Api) { 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()) diff --git a/pkg/api/api.go b/pkg/api/api.go index 8ef9025..1890177 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -194,6 +194,28 @@ func (a *Api) CreateTag(calendar, tagJson string) (string, error) { 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 { diff --git a/pkg/core/core_tags.go b/pkg/core/core_tags.go index 8c39791..df885eb 100644 --- a/pkg/core/core_tags.go +++ b/pkg/core/core_tags.go @@ -42,6 +42,34 @@ func (c *Core) CreateTag(calendar string, tag Tag) (*Tag, error) { 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 + }) + + 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 { From 857d06ade288f0af1e96b84a49c6e65941e77190 Mon Sep 17 00:00:00 2001 From: firu11 Date: Mon, 29 Jun 2026 16:23:28 +0200 Subject: [PATCH 17/21] don't use uuid.Nil anywhere for event.parentId; only for event.Id --- e2e/events_repeating_test.go | 6 +++--- pkg/core/core_events.go | 2 +- pkg/core/event.go | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/e2e/events_repeating_test.go b/e2e/events_repeating_test.go index 22443a4..55de331 100644 --- a/e2e/events_repeating_test.go +++ b/e2e/events_repeating_test.go @@ -150,7 +150,7 @@ func TestRepeatingEvent_Update_Current_DetachesOnlyTargetChild(t *testing.T) { if detached.Repeat != nil { t.Fatalf("detached event should not repeat") } - if detached.ParentId != nil && *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 +226,7 @@ func TestRepeatingEvent_Update_Following_SplitsSeriesFromTargetChild(t *testing. t.Fatalf("failed to update child event with Following strategy: %v", err) } - if newParent.ParentId != nil && *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 +282,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 != nil && *newParent.ParentId != uuid.Nil { + if newParent.ParentId != nil { t.Fatalf("new event should be a parent, got ParentId %s", newParent.ParentId) } diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index 98c8b1a..77beba3 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -292,7 +292,7 @@ func (c *Core) updateCurrentChild(updated *Event) (*Event, error) { detachedEvent := *updated // shallow copy detachedEvent.Repeat = nil // not repeating anymore detachedEvent.ParentId = nil // not child anymore - detachedEvent.Id = uuid.Nil // set to nil; CreateEvent will asign a new one + detachedEvent.Id = uuid.Nil // set to uuid.Nil; CreateEvent will asign a new one return c.CreateEvent(detachedEvent) // save as new } diff --git a/pkg/core/event.go b/pkg/core/event.go index a2b9ad4..77c04dc 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -23,9 +23,9 @@ type Event struct { To time.Time `json:"to"` Calendar string `json:"calendar"` // The name of the calendar the event belongs to. 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 uuid.Nil if the event is basic or parent. + 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. @@ -92,11 +92,11 @@ func (e Event) IsBasic() bool { } func (e Event) IsChild() bool { - return e.ParentId != nil && *e.ParentId != uuid.Nil + return e.ParentId != nil } func (e Event) IsParent() bool { - return (e.ParentId == nil || *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. From fdbd4319e40829cca47aec3b8fd70fdf547e1430 Mon Sep 17 00:00:00 2001 From: firu11 Date: Mon, 29 Jun 2026 17:50:57 +0200 Subject: [PATCH 18/21] add tag tests --- e2e/calendars_test.go | 2 + e2e/events_repeating_test.go | 2 - e2e/tags_test.go | 204 +++++++++++++++++++++++++++++++++++ pkg/core/core_tags.go | 3 + pkg/core/tag.go | 2 +- 5 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 e2e/tags_test.go diff --git a/e2e/calendars_test.go b/e2e/calendars_test.go index 89cf6a5..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() diff --git a/e2e/events_repeating_test.go b/e2e/events_repeating_test.go index 55de331..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) 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/pkg/core/core_tags.go b/pkg/core/core_tags.go index df885eb..c7440d0 100644 --- a/pkg/core/core_tags.go +++ b/pkg/core/core_tags.go @@ -56,6 +56,9 @@ func (c *Core) UpdateTag(calendar string, tag Tag) (*Tag, error) { 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 { diff --git a/pkg/core/tag.go b/pkg/core/tag.go index e539a64..fac2e03 100644 --- a/pkg/core/tag.go +++ b/pkg/core/tag.go @@ -25,7 +25,7 @@ func (t *Tag) Validate() error { } if t.Id != uuid.Nil { // if id is set - if t.Id.Version() != 4 && t.Id.Version() != 8 { // enforce version + if t.Id.Version() != 4 { // enforce version return errors.New("unsupported UUID version") } } else { // if id is unset From 7178acb1fa5111e6376e016ce3ae720f83672969 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 30 Jun 2026 00:11:59 +0200 Subject: [PATCH 19/21] add UpdatedAt to tags for merging --- pkg/core/core_tags.go | 2 ++ pkg/core/git.go | 25 ++++++++++++++++++++----- pkg/core/tag.go | 25 +++++++++++++++---------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/pkg/core/core_tags.go b/pkg/core/core_tags.go index c7440d0..a7deaae 100644 --- a/pkg/core/core_tags.go +++ b/pkg/core/core_tags.go @@ -116,6 +116,8 @@ func (c *Core) tagWorktree(calendar string) (*Calendar, *gogit.Worktree, error) } 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) } 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 index fac2e03..5e5c34c 100644 --- a/pkg/core/tag.go +++ b/pkg/core/tag.go @@ -7,6 +7,7 @@ import ( "io" "path" "strings" + "time" "github.com/git-calendar/core/pkg/encryption" "github.com/go-git/go-billy/v5" @@ -14,9 +15,10 @@ import ( ) type Tag struct { - Id uuid.UUID `json:"id"` - Name string `json:"name"` - Color string `json:"color"` + Id uuid.UUID `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + UpdatedAt time.Time `json:"-"` } func (t *Tag) Validate() error { @@ -48,22 +50,25 @@ func (tag Tag) getPath() string { // tagFile represents a tag inside file. type tagInFile struct { - Name string `json:"name,omitzero"` - Color string `json:"color,omitzero"` + 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, + Id: id, + Name: tf.Name, + Color: tf.Color, + UpdatedAt: tf.UpdatedAt, } } func (e Tag) fileData() tagInFile { return tagInFile{ - Name: e.Name, - Color: e.Color, + Name: e.Name, + Color: e.Color, + UpdatedAt: e.UpdatedAt, } } From a0633a554904d3ab5600529c29f088dc0fa313b7 Mon Sep 17 00:00:00 2001 From: firu11 Date: Wed, 1 Jul 2026 18:30:43 +0200 Subject: [PATCH 20/21] typo --- notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notes.md b/notes.md index fbf5813..bc13977 100644 --- a/notes.md +++ b/notes.md @@ -66,7 +66,7 @@ ### 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 From a901dd4a5b5133543c2141fa9ef767611c3715b7 Mon Sep 17 00:00:00 2001 From: firu11 Date: Wed, 1 Jul 2026 18:37:29 +0200 Subject: [PATCH 21/21] comments --- pkg/core/tag.go | 3 ++- pkg/core/utils.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/core/tag.go b/pkg/core/tag.go index 5e5c34c..ab9f595 100644 --- a/pkg/core/tag.go +++ b/pkg/core/tag.go @@ -14,6 +14,7 @@ import ( "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"` @@ -48,7 +49,7 @@ func (tag Tag) getPath() string { // ---------------------------------------------------------------- -// tagFile represents a tag inside file. +// tagInFile represents a tag inside file. type tagInFile struct { Name string `json:"name,omitzero"` Color string `json:"color,omitzero"` 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