nextcloud-calendar/src/fullcalendar/eventSources/freeBusyBlockedForAllEventS...

198 lines
6.3 KiB
JavaScript

/*
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import getTimezoneManager from '../../services/timezoneDataProviderService.js'
import { createFreeBusyRequest, getParserManager } from 'calendar-js'
import DateTimeValue from 'calendar-js/src/values/dateTimeValue.js'
import { findSchedulingOutbox } from '../../services/caldavService.js'
import logger from '../../utils/logger.js'
import AttendeeProperty from 'calendar-js/src/properties/attendeeProperty.js'
/**
* Returns an event source for free-busy
*
* @param {AttendeeProperty} organizer The organizer of the event
* @param {AttendeeProperty[]} attendees Array of the event's attendees
* @param {String[]} resources List of resources
* @returns {{startEditable: boolean, resourceEditable: boolean, editable: boolean, id: string, durationEditable: boolean, events: events}}
*/
export default function(organizer, attendees, resources) {
const resourceIds = resources.map((resource) => resource.id)
return {
id: 'free-busy-free-for-all',
editable: false,
startEditable: false,
durationEditable: false,
resourceEditable: false,
events: async({
start,
end,
timeZone,
}, successCallback, failureCallback) => {
console.debug('freeBusyBlockedForAllEventSource', start, end, timeZone)
let timezoneObject = getTimezoneManager().getTimezoneForId(timeZone)
if (!timezoneObject) {
timezoneObject = getTimezoneManager().getTimezoneForId('UTC')
logger.error(`FreeBusyEventSource: Timezone ${timeZone} not found, falling back to UTC.`)
}
const startDateTime = DateTimeValue.fromJSDate(start, true)
const endDateTime = DateTimeValue.fromJSDate(end, true)
const organizerAsAttendee = new AttendeeProperty('ATTENDEE', organizer.email)
const freeBusyComponent = createFreeBusyRequest(startDateTime, endDateTime, organizer, [organizerAsAttendee, ...attendees])
const freeBusyICS = freeBusyComponent.toICS()
let outbox
try {
outbox = await findSchedulingOutbox()
} catch (error) {
failureCallback(error)
return
}
let freeBusyData
try {
freeBusyData = await outbox.freeBusyRequest(freeBusyICS)
} catch (error) {
failureCallback(error)
return
}
const slots = []
for (const [, data] of Object.entries(freeBusyData)) {
if (!data.success) {
continue
}
const parserManager = getParserManager()
const parser = parserManager.getParserForFileType('text/calendar')
parser.parse(data.calendarData)
// TODO: fix me upstream, parser only exports VEVENT, VJOURNAL and VTODO at the moment
const calendarComponent = parser._calendarComponent
const freeBusyComponent = calendarComponent.getFirstComponent('VFREEBUSY')
if (!freeBusyComponent) {
continue
}
for (const freeBusyProperty of freeBusyComponent.getPropertyIterator('FREEBUSY')) {
if (freeBusyProperty.type === 'FREE') {
// We care about anything BUT free slots
continue
}
slots.push({
start: freeBusyProperty.getFirstValue().start.getInTimezone(timezoneObject).jsDate,
end: freeBusyProperty.getFirstValue().end.getInTimezone(timezoneObject).jsDate,
})
}
}
// Now that we have all the busy slots we try to combine them to iron
// out any overlaps between them.
// The algorithm below will sort the slots by their start time ane then
// iteratively collapse anything that starts and stops within the same
// time. The complexity of this algorithms is n^2, but assuming the
// number of attendees of an event is relatively low, this should be
// fine to calculate.
slots.sort((a, b) => a.start - b.start)
const slotsWithoutOverlap = []
if (slots.length) {
let currentSlotStart = slots[0].start
slots.forEach(slot => {
const combined = findNextCombinedSlot(slots, currentSlotStart) ?? slot
if (combined.start < currentSlotStart) {
// This slot has already been combined with a former slot
return
}
slotsWithoutOverlap.push(combined)
currentSlotStart = combined.end
})
}
console.debug('deduplicated slots', slots, slotsWithoutOverlap)
const events = slotsWithoutOverlap.map(slot => {
return {
groupId: 'free-busy-blocked-for-all',
start: slot.start.toISOString(),
end: slot.end.toISOString(),
resourceIds,
display: 'background',
allDay: false,
backgroundColor: 'var(--color-text-maxcontrast)',
borderColor: 'var(--color-text-maxcontrast)',
}
})
console.debug('freeBusyBlockedForAllEventSource', slots, events)
successCallback(events)
},
}
}
/***
* Try to combine slots after the given starting date
*
* @param {{start: Date, end: Date}[]} slots The slots to combine
* @param {Date} start Combine slots after this date
* @returns {undefined|{start: Date, end: Date}} The combined date or undefined if no overlaps were found
*/
function findNextCombinedSlot(slots, start) {
const slot = slots
.filter(slot => slot.start >= start)
.reduce((combined, slot) => {
if (slot.start < combined.start) {
// This slot starts too early
return combined
}
if (slot.end <= combined.end) {
// This slots starts and ends within the combined one
return combined
}
if (slot.start > combined.end) {
// This slots starts after the the combined one
return combined
}
// The slot is extended
return {
start: combined.start,
end: slot.end,
}
}, {
start,
end: start,
})
if (slot.start === slot.end) {
// Empty -> no slot
return undefined
}
return slot
}