diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index 5326d81..122f02a 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -136,7 +136,8 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) continue // owner's calendar no longer exists } localName := sharedCalendarName(sh.Owner, sh.CalendarName) - cals = append(cals, b.calendarMeta(sh.Owner, sh.CalendarName, localName)) + displayName := b.sharedDisplayName(p.Username, sh.Owner, sh.CalendarName) + cals = append(cals, b.calendarMetaNamed(sh.Owner, sh.CalendarName, localName, displayName)) } } @@ -163,7 +164,11 @@ func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Cale if _, err := b.store.GetCollection(owner, "cal-"+realName); err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } - cal := b.calendarMeta(owner, realName, localName) + displayName := realName + if owner != requester { + displayName = b.sharedDisplayName(requester, owner, realName) + } + cal := b.calendarMetaNamed(owner, realName, localName, displayName) return &cal, nil } @@ -369,6 +374,14 @@ func (b *Backend) resolveCalendar(requester, localName string, requireWrite bool } func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calendar { + return b.calendarMetaNamed(owner, realName, localName, realName) +} + +// calendarMetaNamed is calendarMeta with an explicit displayName, used for +// a calendar shared with the requester whose name collides with one of +// their own calendars (see sharedDisplayName) — the Description still +// always names the real calendar, only the client-visible Name changes. +func (b *Backend) calendarMetaNamed(owner, realName, localName, displayName string) caldav.Calendar { desc := fmt.Sprintf("%s's %s calendar", owner, realName) return caldav.Calendar{ Path: calHomePath() + localName + "/", @@ -376,13 +389,46 @@ func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calenda // "owner~name" synthetic local name used internally to keep a // shared calendar's URL unique) — clients like DAVx5 show this as // the calendar's label, and "alice~work" looked confusing there. - Name: realName, + Name: displayName, Description: desc, SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"}, MaxResourceSize: 10 * 1024 * 1024, // 10 MiB } } +// sharedDisplayName returns realName, suffixed with the owning user's +// name in parentheses (e.g. "Work (bob)") if requester has another +// calendar with the exact same display name — either one of their own, +// or another share from a different owner — so DAVx5 (and other clients) +// don't show two calendars with an identical, indistinguishable title. +// Only called for calendars shared with requester; requester's own +// calendars never need disambiguating against themselves. +func (b *Backend) sharedDisplayName(requester, owner, realName string) string { + own, err := b.dbase.ListCalendars(requester) + if err != nil { + b.logger.Warn("listing own calendars for disambiguation", "error", err) + } + for _, c := range own { + if c.Name == realName { + return fmt.Sprintf("%s (%s)", realName, owner) + } + } + + shares, err := b.dbase.CalendarsSharedWith(requester) + if err != nil { + b.logger.Warn("listing shared calendars for disambiguation", "error", err) + } + for _, sh := range shares { + if sh.Owner == owner && sh.CalendarName == realName { + continue // the calendar itself, not a collision + } + if sh.CalendarName == realName { + return fmt.Sprintf("%s (%s)", realName, owner) + } + } + return realName +} + func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) { // Some clients (Outlook/Exchange, some Thunderbird/Lightning setups) // write Windows timezone names into TZID instead of IANA ones, which diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go index bbbbb1f..9f3dc3b 100644 --- a/internal/caldav/backend_test.go +++ b/internal/caldav/backend_test.go @@ -277,3 +277,57 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) { t.Fatalf("expected a single 200 OK propstat with no 404, got: %s", body) } } + +// TestSharedCalendarNameCollidingWithOwnGetsDisambiguated covers the case +// where a calendar shared with a user has the same name as one of that +// user's own calendars: DAVx5 (and any other client) would otherwise show +// two calendars with an identical, indistinguishable title, so the shared +// one's display name gets the owner's username appended in parentheses. +func TestSharedCalendarNameCollidingWithOwnGetsDisambiguated(t *testing.T) { + b, dbase := newTestBackend(t) + + // alice creates her own "personal" calendar, colliding with bob's + // pre-existing "personal" calendar (see newTestBackend), then shares + // it with bob. + if err := dbase.CreateCalendar("alice", "personal"); err != nil { + t.Fatalf("CreateCalendar alice/personal: %v", err) + } + if err := dbase.ShareCalendar("alice", "personal", "bob", db.PermRead); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + if _, err := b.ListCalendars(ctxFor("alice")); err != nil { + t.Fatalf("ListCalendars(alice): %v", err) + } + + cals, err := b.ListCalendars(ctxFor("bob")) + if err != nil { + t.Fatalf("ListCalendars(bob): %v", err) + } + + wantPath := calHomePath() + sharedCalendarName("alice", "personal") + "/" + var sharedName, ownName string + for _, c := range cals { + if c.Path == wantPath { + sharedName = c.Name + } + if c.Path == calHomePath()+"personal/" { + ownName = c.Name + } + } + if ownName != "personal" { + t.Fatalf("expected bob's own calendar to keep its plain name, got %q", ownName) + } + if want := "personal (alice)"; sharedName != want { + t.Fatalf("expected shared calendar display name %q, got %q", want, sharedName) + } + + // GetCalendar (single-collection lookup, as used by PROPFIND on the + // calendar's own URL) must disambiguate the same way. + cal, err := b.GetCalendar(ctxFor("bob"), wantPath) + if err != nil { + t.Fatalf("GetCalendar: %v", err) + } + if want := "personal (alice)"; cal.Name != want { + t.Fatalf("GetCalendar: expected display name %q, got %q", want, cal.Name) + } +}