mirror of https://github.com/nextcloud/calendar
276 lines
8.5 KiB
JavaScript
276 lines
8.5 KiB
JavaScript
/**
|
|
* @copyright 2024 Grigory Vodyanov <scratchx@gmx.com>
|
|
*
|
|
* @author 2024 Grigory Vodyanov <scratchx@gmx.com>
|
|
*
|
|
* @license AGPL-3.0-or-later
|
|
*
|
|
* 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 { AttendeeProperty, createFreeBusyRequest, DateTimeValue } from '@nextcloud/calendar-js'
|
|
import { findSchedulingOutbox } from './caldavService.js'
|
|
import freeBusyResourceEventSourceFunction from '../fullcalendar/eventSources/freeBusyResourceEventSourceFunction.js'
|
|
import getTimezoneManager from './timezoneDataProviderService.js'
|
|
import logger from '../utils/logger.js'
|
|
|
|
/**
|
|
* Get the first available slot for an event using freebusy API
|
|
*
|
|
* @param {AttendeeProperty} organizer The organizer of the event
|
|
* @param {AttendeeProperty[]} attendees Array of the event's attendees
|
|
* @param {Date} start The start date and time of the event
|
|
* @param {Date} end The end date and time of the event
|
|
* @param timeZoneId
|
|
* @return {Promise<>}
|
|
*/
|
|
export async function getBusySlots(organizer, attendees, start, end, timeZoneId) {
|
|
|
|
let timezoneObject = getTimezoneManager().getTimezoneForId(timeZoneId)
|
|
if (!timezoneObject) {
|
|
timezoneObject = getTimezoneManager().getTimezoneForId('UTC')
|
|
logger.error(`FreeBusyEventSource: Timezone ${timeZoneId} 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) {
|
|
return { error }
|
|
}
|
|
|
|
let freeBusyData
|
|
try {
|
|
freeBusyData = await outbox.freeBusyRequest(freeBusyICS)
|
|
} catch (error) {
|
|
return { error }
|
|
}
|
|
const events = []
|
|
for (const [uri, data] of Object.entries(freeBusyData)) {
|
|
events.push(...freeBusyResourceEventSourceFunction(uri, data.calendarData, data.success, startDateTime, endDateTime, timezoneObject))
|
|
}
|
|
|
|
return { events }
|
|
}
|
|
|
|
/**
|
|
* Get the first available slot for an event using the freebusy API
|
|
*
|
|
|
|
* @param {Date} start The start date and time of the event
|
|
* @param {Date} end The end date and time of the event
|
|
* @param retrievedEvents Events found by the freebusy API
|
|
* @return []
|
|
*/
|
|
export function getFirstFreeSlot(start, end, retrievedEvents) {
|
|
let duration = getDurationInSeconds(start, end)
|
|
if (duration === 0) {
|
|
duration = 86400 // one day
|
|
}
|
|
const endSearchDate = new Date(start)
|
|
endSearchDate.setDate(start.getDate() + 7)
|
|
|
|
if (retrievedEvents.error) {
|
|
return [{ error: retrievedEvents.error }]
|
|
}
|
|
|
|
const events = sortEvents(retrievedEvents)
|
|
|
|
let currentCheckedTime = start
|
|
const currentCheckedTimeEnd = new Date(currentCheckedTime)
|
|
currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration)
|
|
const foundSlots = []
|
|
let offset = 1
|
|
|
|
if (new Date(events[0]?.start) < currentCheckedTime) {
|
|
offset = 0
|
|
}
|
|
|
|
for (let i = 0; i < events.length + offset && i < 5; i++) {
|
|
foundSlots[i] = checkTimes(currentCheckedTime, duration, events)
|
|
|
|
if (foundSlots[i].nextEvent !== undefined && foundSlots[i].nextEvent !== null) {
|
|
currentCheckedTime = new Date(foundSlots[i].nextEvent.end)
|
|
}
|
|
// avoid repetitions caused by events blocking at first iteration of currentCheckedTime
|
|
if (foundSlots[i]?.start === foundSlots[i - 1]?.start && foundSlots[i] !== undefined) {
|
|
foundSlots[i] = {}
|
|
break
|
|
}
|
|
}
|
|
|
|
const roundedSlots = []
|
|
|
|
foundSlots.forEach((slot) => {
|
|
const roundedTime = roundTime(slot.start, slot.end, slot.blockingEvent, slot.nextEvent, duration)
|
|
|
|
if (roundedTime !== null && roundedTime.start < endSearchDate) {
|
|
roundedSlots.push({
|
|
start: roundedTime.start,
|
|
end: roundedTime.end,
|
|
})
|
|
}
|
|
})
|
|
|
|
return roundedSlots
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param start
|
|
* @param end
|
|
* @return {number}
|
|
*/
|
|
function getDurationInSeconds(start, end) {
|
|
// convert dates to UTC to account for daylight saving time
|
|
const startUTC = new Date(start).toUTCString()
|
|
const endUTC = new Date(end).toUTCString()
|
|
|
|
const durationMs = new Date(endUTC) - new Date(startUTC)
|
|
// convert milliseconds to seconds
|
|
return Math.floor(durationMs / 1000)
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param currentCheckedTime
|
|
* @param currentCheckedTimeEnd
|
|
* @param blockingEvent
|
|
* @param nextEvent
|
|
* @param duration
|
|
*/
|
|
function roundTime(currentCheckedTime, currentCheckedTimeEnd, blockingEvent, nextEvent, duration) {
|
|
if (currentCheckedTime === null) return null
|
|
if (!blockingEvent) return { start: currentCheckedTime, end: currentCheckedTimeEnd }
|
|
|
|
// make sure that difference between currentCheckedTime and blockingEvent.end is at least 15 minutes
|
|
if ((currentCheckedTime - new Date(blockingEvent.end)) / (1000 * 60) < 15) {
|
|
currentCheckedTime.setMinutes(currentCheckedTime.getMinutes() + 15)
|
|
}
|
|
|
|
// needed to fix edge case errors
|
|
if ((currentCheckedTime - new Date(blockingEvent.end)) / (1000 * 60) > 15) {
|
|
currentCheckedTime.setMinutes(currentCheckedTime.getMinutes() - 15)
|
|
}
|
|
|
|
// round to the nearest 30 minutes
|
|
if (currentCheckedTime.getMinutes() < 30) {
|
|
currentCheckedTime.setMinutes(30)
|
|
} else {
|
|
currentCheckedTime.setMinutes(0)
|
|
currentCheckedTime.setHours(currentCheckedTime.getHours() + 1)
|
|
}
|
|
|
|
// update currentCheckedTimeEnd again since currentCheckedTime was updated
|
|
currentCheckedTimeEnd = new Date(currentCheckedTime)
|
|
currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration)
|
|
|
|
// if the rounding of the event doesn't conflict with the start of the next one
|
|
if (currentCheckedTimeEnd > new Date(nextEvent?.start)) {
|
|
return null
|
|
}
|
|
|
|
return { start: currentCheckedTime, end: currentCheckedTimeEnd }
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param currentCheckedTime
|
|
* @param duration
|
|
* @param events
|
|
*/
|
|
function checkTimes(currentCheckedTime, duration, events) {
|
|
let slotIsBusy = false
|
|
let blockingEvent = null
|
|
let nextEvent = null
|
|
let currentCheckedTimeEnd = new Date(currentCheckedTime)
|
|
currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration)
|
|
|
|
// loop every 5 minutes since start date
|
|
// check if there are no events in the duration starting from that minute
|
|
while (true) {
|
|
events.every(
|
|
(event) => {
|
|
slotIsBusy = false
|
|
|
|
const eventStart = new Date(event.start)
|
|
const eventEnd = new Date(event.end)
|
|
|
|
currentCheckedTimeEnd = new Date(currentCheckedTime)
|
|
currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration)
|
|
|
|
// start of event is within the range that we are checking
|
|
if (eventStart >= currentCheckedTime && eventStart <= currentCheckedTimeEnd) {
|
|
slotIsBusy = true
|
|
blockingEvent = event
|
|
return false
|
|
}
|
|
|
|
// end of event is within range that we are checking
|
|
if (eventEnd >= currentCheckedTime && eventEnd <= currentCheckedTimeEnd) {
|
|
slotIsBusy = true
|
|
blockingEvent = event
|
|
return false
|
|
}
|
|
|
|
// range that we are checking is within ends of event
|
|
if (eventStart <= currentCheckedTime && eventEnd >= currentCheckedTimeEnd) {
|
|
slotIsBusy = true
|
|
blockingEvent = event
|
|
return false
|
|
}
|
|
return true
|
|
},
|
|
)
|
|
|
|
if (slotIsBusy) {
|
|
currentCheckedTime.setMinutes(currentCheckedTime.getMinutes() + 5)
|
|
} else break
|
|
}
|
|
|
|
if (blockingEvent !== null) {
|
|
const blockingIndex = events.findIndex((event) => event === blockingEvent)
|
|
|
|
nextEvent = events[blockingIndex + 1]
|
|
} else {
|
|
if (events.length > 0) nextEvent = events[0]
|
|
}
|
|
|
|
return { start: currentCheckedTime, end: currentCheckedTimeEnd, nextEvent, blockingEvent }
|
|
}
|
|
|
|
// make a function that sorts a list of objects by the "start" property
|
|
function sortEvents(events) {
|
|
// remove events that have the same start and end time, if not done causes problems
|
|
const mappedEvents = new Map()
|
|
|
|
for (const obj of events) {
|
|
const key = obj.start.toString() + obj.end.toString()
|
|
|
|
if (!mappedEvents.has(key)) {
|
|
mappedEvents.set(key, obj)
|
|
}
|
|
}
|
|
|
|
return Array.from(mappedEvents.values()).sort((a, b) => new Date(a.start) - new Date(b.start))
|
|
}
|