Disambiguate shared calendars with a name colliding with the owner's
If a calendar shared with a user has the same name as one of the user's own calendars (or as another share from a different owner), DAVx5 would show two calendars with an identical, indistinguishable title. The shared calendar's CalDAV display name (Calendar.Name, exposed via displayname/DAVx5's calendar list) now gets the owning user's username appended in parentheses in that case, e.g. "personal (alice)" — the underlying calendar path/URL is unaffected, and a user's own calendars are never renamed (only shared ones can collide with something else). internal/caldav/backend.go: calendarMeta split into calendarMeta (own calendars, unchanged name) and calendarMetaNamed (explicit display name); new sharedDisplayName(requester, owner, realName) checks requester's own calendars and other shares for a collision. Wired into both ListCalendars and GetCalendar (the latter used by direct PROPFIND/REPORT against a shared calendar's own URL). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user