From a66d94fb06d66a58d0eb7728c7047e11dd79151f Mon Sep 17 00:00:00 2001 From: Rik Dekker Date: Tue, 5 May 2026 22:44:57 +0200 Subject: [PATCH] feat(principal): map CalDAV room metadata properties Extend the principal model to extract room-seating-capacity, room-type, room-features, room-building-address, and room-building-room-number from CalDAV principal responses. These properties are defined in the CalDAV standard and already served by Nextcloud room backends, but not yet used by the Calendar frontend. Mapping them into the principal model makes them available for any future UI improvement (e.g. a browsable room finder) without changing how principals are fetched. Backward compatible: properties default to null when not provided by the backend. Also derives roomBuildingName from the building address (first segment) and constructs a roomAddress string suitable for the event LOCATION field. Signed-off-by: Rik Dekker --- src/models/principal.js | 47 +++++++ .../javascript/unit/models/principal.test.js | 130 ++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/src/models/principal.js b/src/models/principal.js index 1e50eaeed5..fe4cddd8aa 100644 --- a/src/models/principal.js +++ b/src/models/principal.js @@ -50,6 +50,14 @@ function getDefaultPrincipalObject(props) { principalId: null, // The url of the default calendar for invitations scheduleDefaultCalendarUrl: null, + // Room-specific properties (only for calendar-rooms) + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, ...props, } } @@ -91,6 +99,38 @@ function mapDavToPrincipal(dav) { const url = dav.principalUrl const userId = dav.userId + // Extract room-specific properties from DAV object, trimming string values defensively + const roomSeatingCapacity = dav.roomSeatingCapacity ?? null + const roomType = (dav.roomType ?? '').toString().trim() || null + const roomFeatures = (dav.roomFeatures ?? '').toString().trim() || null + // Strip leading/trailing whitespace and commas from building address to handle empty + // building-name fields, e.g. ", Science Park 140, 1098 XG, Amsterdam" → "Science Park 140, 1098 XG, Amsterdam" + const rawBuildingAddress = dav.roomBuildingAddress ?? null + const roomBuildingAddress = rawBuildingAddress + ? rawBuildingAddress.replace(/^[\s,]+|[\s,]+$/g, '').trim() || null + : null + // Derive building name from address (everything before first comma): "Poppodium, Kerkstraat 10" → "Poppodium" + const roomBuildingName = roomBuildingAddress ? roomBuildingAddress.split(',')[0].trim() || null : null + // Room number (floor.room format, e.g. "2.17") is stored in room-building-room-number + const roomNumber = (dav.roomBuildingRoomNumber ?? '').toString().trim() || null + + // Construct roomAddress for event LOCATION field from available properties + // Format: "Street (Building, Room X.XX)" — street-first for map/navigation apps + let roomAddress = null + if (roomBuildingAddress) { + const commaIdx = roomBuildingAddress.indexOf(',') + if (commaIdx > 0) { + const building = roomBuildingAddress.substring(0, commaIdx).trim() + const street = roomBuildingAddress.substring(commaIdx + 1).trim() + const detail = roomNumber ? building + ', Room ' + roomNumber : building + roomAddress = street + ' (' + detail + ')' + } else { + roomAddress = roomNumber + ? roomBuildingAddress + ' (Room ' + roomNumber + ')' + : roomBuildingAddress + } + } + return getDefaultPrincipalObject({ id, calendarUserType, @@ -107,6 +147,13 @@ function mapDavToPrincipal(dav) { principalId, userId, scheduleDefaultCalendarUrl, + roomSeatingCapacity, + roomType, + roomAddress, + roomFeatures, + roomBuildingName, + roomBuildingAddress, + roomNumber, }) } diff --git a/tests/javascript/unit/models/principal.test.js b/tests/javascript/unit/models/principal.test.js index 5bcdb92750..e8712a2921 100644 --- a/tests/javascript/unit/models/principal.test.js +++ b/tests/javascript/unit/models/principal.test.js @@ -24,6 +24,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: null, scheduleDefaultCalendarUrl: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -48,6 +55,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { principalId: 'bar', otherProp: 'foo', scheduleDefaultCalendarUrl: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -82,6 +96,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'jane.doe', userId: 'legacy-jane-doe-uid', + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -116,6 +137,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'jane.doe', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -150,6 +178,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'CGAH82BAS285H', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -184,6 +219,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'projector-123', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -218,9 +260,90 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: true, principalId: 'room-123', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) + it('should properly map a calendar-room-principal with room properties', () => { + const dav = { + addressBookHomes: undefined, + calendarHomes: [], + calendarUserAddressSet: [], + calendarUserType: 'ROOM', + displayname: 'Conference Room A', + email: 'conf-a@example.com', + principalScheme: 'principal:principals/calendar-rooms/conf-a', + principalUrl: '/remote.php/dav/principals/calendar-rooms/conf-a/', + scheduleInbox: null, + scheduleOutbox: null, + url: '/remote.php/dav/principals/calendar-rooms/conf-a/', + userId: null, + roomSeatingCapacity: 20, + roomType: 'conference-room', + roomFeatures: 'PROJECTOR,WHITEBOARD', + roomBuildingAddress: 'Building A, Main Street 1', + roomBuildingRoomNumber: '2.17', + } + + expect(mapDavToPrincipal(dav)).toEqual({ + id: 'L3JlbW90ZS5waHAvZGF2L3ByaW5jaXBhbHMvY2FsZW5kYXItcm9vbXMvY29uZi1hLw==', + dav, + calendarUserType: 'ROOM', + principalScheme: 'principal:principals/calendar-rooms/conf-a', + emailAddress: 'conf-a@example.com', + displayname: 'Conference Room A', + url: '/remote.php/dav/principals/calendar-rooms/conf-a/', + isUser: false, + isGroup: false, + isCircle: false, + isCalendarResource: false, + isCalendarRoom: true, + principalId: 'conf-a', + userId: null, + roomSeatingCapacity: 20, + roomType: 'conference-room', + roomFeatures: 'PROJECTOR,WHITEBOARD', + roomBuildingName: 'Building A', + roomBuildingAddress: 'Building A, Main Street 1', + roomNumber: '2.17', + roomAddress: 'Main Street 1 (Building A, Room 2.17)', + }) + }) + + it('should strip leading commas and whitespace from roomBuildingAddress', () => { + const dav = { + addressBookHomes: undefined, + calendarHomes: [], + calendarUserAddressSet: [], + calendarUserType: 'ROOM', + displayname: 'AMS 0.11', + email: 'ams-011@example.com', + principalScheme: 'principal:principals/calendar-rooms/ams-011', + principalUrl: '/remote.php/dav/principals/calendar-rooms/ams-011/', + scheduleInbox: null, + scheduleOutbox: null, + url: '/remote.php/dav/principals/calendar-rooms/ams-011/', + userId: null, + roomSeatingCapacity: 1, + roomType: 'meeting-room', + roomFeatures: ' ', + roomBuildingAddress: ', Science Park 140, 1098 XG, Amsterdam', + roomBuildingRoomNumber: '0.11', + } + + const result = mapDavToPrincipal(dav) + expect(result.roomBuildingAddress).toBe('Science Park 140, 1098 XG, Amsterdam') + expect(result.roomBuildingName).toBe('Science Park 140') + expect(result.roomFeatures).toBe(null) + expect(result.roomAddress).toBe('1098 XG, Amsterdam (Science Park 140, Room 0.11)') + }) + it('should properly map a principal from an unknown backend to principal-object', () => { const dav = { addressBookHomes: undefined, @@ -252,6 +375,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: null, userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) })