@@ -73,10 +77,11 @@ import {
NcButton,
NcLoadingIcon,
NcModal as Modal,
+ NcNoteCard,
} from '@nextcloud/vue'
import autosize from '../../directives/autosize.js'
-import { timeStampToLocaleTime } from '../../utils/localeTime.js'
+import { timeStampToLocaleTime, timeStampToLocaleDate } from '../../utils/localeTime.js'
export default {
name: 'AppointmentDetails',
@@ -85,6 +90,7 @@ export default {
NcButton,
NcLoadingIcon,
Modal,
+ NcNoteCard,
},
directives: {
autosize,
@@ -110,6 +116,10 @@ export default {
required: true,
type: String,
},
+ showRateLimitingWarning: {
+ required: true,
+ type: Boolean,
+ },
showError: {
required: true,
type: Boolean,
@@ -134,6 +144,9 @@ export default {
endTime() {
return timeStampToLocaleTime(this.timeSlot.end, this.timeZoneId)
},
+ date() {
+ return timeStampToLocaleDate(this.timeSlot.start, this.timeZoneId)
+ }
},
methods: {
save() {
diff --git a/src/components/Editor/Attachments/AttachmentsList.vue b/src/components/Editor/Attachments/AttachmentsList.vue
index 2220c74c11cabb02817fa6bc665b5282e4da9c45..c95d82ba7e9784e1675f54170ab25c10aeef7ec1 100644
--- a/src/components/Editor/Attachments/AttachmentsList.vue
+++ b/src/components/Editor/Attachments/AttachmentsList.vue
@@ -65,10 +65,6 @@ import {
NcActionButton,
} from '@nextcloud/vue'
-import {
- mapState,
-} from 'vuex'
-
import Upload from 'vue-material-design-icons/Upload.vue'
import Close from 'vue-material-design-icons/Close.vue'
import Folder from 'vue-material-design-icons/Folder.vue'
@@ -77,10 +73,10 @@ import Plus from 'vue-material-design-icons/Plus.vue'
import { generateUrl } from '@nextcloud/router'
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
+import logger from '../../../utils/logger.js'
import {
uploadLocalAttachment,
getFileInfo,
- createFolder,
} from '../../../services/attachmentService.js'
import { parseXML } from 'webdav'
@@ -109,7 +105,6 @@ export default {
data() {
return {
uploading: false,
- folderCreated: false,
}
},
computed: {
@@ -119,9 +114,6 @@ export default {
attachments() {
return this.calendarObjectInstance.attachments
},
- ...mapState({
- attachmentsFolder: state => state.settings.attachmentsFolder,
- }),
},
methods: {
addAttachmentWithProperty(calendarObjectInstance, sharedData) {
@@ -142,7 +134,7 @@ export default {
const filename = await picker.pick(t('calendar', 'Choose a file to share as a link'))
if (!this.isDuplicateAttachment(filename)) {
// TODO do not share Move this to PHP
- const data = await getFileInfo(filename, this.currentUser.dav)
+ const data = await getFileInfo(filename, this.currentUser.dav.userId)
const davRes = await parseXML(data)
const davRespObj = davRes?.multistatus?.response[0]?.propstat?.prop
davRespObj.fileName = filename
@@ -168,24 +160,25 @@ export default {
this.$refs.localAttachments.click()
},
async onLocalAttachmentSelected(e) {
- if (!this.folderCreated) {
- await createFolder(this.attachmentsFolder, this.currentUser.userId)
- this.folderCreated = true
- }
- const attachments = await uploadLocalAttachment(this.attachmentsFolder, e, this.currentUser.dav, this.attachments)
- // TODO do not share file, move to PHP
- attachments.map(async attachment => {
- const data = await getFileInfo(`${this.attachmentsFolder}/${attachment.path}`, this.currentUser.dav)
- const davRes = await parseXML(data)
- const davRespObj = davRes?.multistatus?.response[0]?.propstat?.prop
- davRespObj.fileName = attachment.path
- davRespObj.url = generateUrl(`/f/${davRespObj.fileid}`)
- davRespObj.value = davRespObj.url
- this.addAttachmentWithProperty(this.calendarObjectInstance, davRespObj)
- })
-
- e.target.value = ''
+ try {
+ const attachmentsFolder = await this.$store.dispatch('createAttachmentsFolder')
+ const attachments = await uploadLocalAttachment(attachmentsFolder, Array.from(e.target.files), this.currentUser.dav, this.attachments)
+ // TODO do not share file, move to PHP
+ attachments.map(async attachment => {
+ const data = await getFileInfo(`${attachmentsFolder}/${attachment.path}`, this.currentUser.dav.userId)
+ const davRes = await parseXML(data)
+ const davRespObj = davRes?.multistatus?.response[0]?.propstat?.prop
+ davRespObj.fileName = attachment.path
+ davRespObj.url = generateUrl(`/f/${davRespObj.fileid}`)
+ davRespObj.value = davRespObj.url
+ this.addAttachmentWithProperty(this.calendarObjectInstance, davRespObj)
+ })
+ e.target.value = ''
+ } catch (error) {
+ logger.error('Could not upload attachment(s)', { error })
+ showError(t('calendar', 'Could not upload attachment(s)'))
+ }
},
getIcon(mime) {
return OC.MimeType.getIconUrl(mime)
diff --git a/src/components/Editor/AvatarParticipationStatus.vue b/src/components/Editor/AvatarParticipationStatus.vue
index f599791638ab9924313f75dcb5c338a991dc69fc..d89961606115e43c9f243850c53df9cc57ef29c8 100644
--- a/src/components/Editor/AvatarParticipationStatus.vue
+++ b/src/components/Editor/AvatarParticipationStatus.vue
@@ -119,7 +119,7 @@
- {{ t('calendar', 'Invitation sent') }}
+ {{ t('calendar', 'Awaiting response') }}
diff --git a/src/components/Editor/Invitees/InviteesListItem.vue b/src/components/Editor/Invitees/InviteesListItem.vue
index 405065fd211d907dc9a467f07847bd3858a3e7ea..30b3d53d00cd7ff3ad774efab61f178d856bbfed 100644
--- a/src/components/Editor/Invitees/InviteesListItem.vue
+++ b/src/components/Editor/Invitees/InviteesListItem.vue
@@ -57,7 +57,7 @@
- {{ $t('calendar', 'Send email') }}
+ {{ $t('calendar', 'Request reply') }}
*
* @author 2022 Mikhail Sazanov
+ * @author Richard Steinmetz
*
* @license AGPL-3.0-or-later
*
@@ -24,6 +25,7 @@ import axios from '@nextcloud/axios'
import { generateOcsUrl, generateRemoteUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
+import { parseXML } from 'webdav'
/**
* Makes a share link for a given file or directory.
@@ -85,20 +87,47 @@ const shareFileWith = async function(path, sharedWith, permissions = 17) {
const createFolder = async function(folderName, userId) {
const url = generateRemoteUrl(`dav/files/${userId}/${folderName}`)
- await axios({
- method: 'MKCOL',
- url,
- }).catch(e => {
- if (e.response.status !== 405) {
+ try {
+ await axios({
+ method: 'MKCOL',
+ url,
+ })
+ } catch (e) {
+ if (e?.response?.status !== 405) {
showError(t('calendar', 'Error creating a folder {folder}', {
folder: folderName,
}))
+ // Maybe the actual upload succeeds -> keep going
+ return folderName
}
- })
+
+ // Folder already exists
+ if (folderName !== '/') {
+ folderName = await findFirstOwnedFolder(folderName, userId)
+ }
+ }
+
+ return folderName
+}
+
+const findFirstOwnedFolder = async function(path, userId) {
+ const infoXml = await getFileInfo(path, userId)
+ const info = await parseXML(infoXml)
+ const mountType = info?.multistatus?.response[0]?.propstat?.prop?.['mount-type']
+ if (mountType !== 'shared') {
+ return path
+ }
+
+ const hierarchy = path.split('/')
+ hierarchy.pop()
+ if (hierarchy.length === 1) {
+ return '/'
+ }
+
+ return findFirstOwnedFolder(hierarchy.join('/'), userId)
}
-const uploadLocalAttachment = async function(folder, event, dav, componentAttachments) {
- const files = event.target.files
+const uploadLocalAttachment = async function(folder, files, dav, componentAttachments) {
const attachments = []
const promises = []
@@ -140,8 +169,8 @@ const uploadLocalAttachment = async function(folder, event, dav, componentAttach
}
// TODO is shared or not @share-types@
-const getFileInfo = async function(path, dav) {
- const url = generateRemoteUrl(`dav/files/${dav.userId}/${path}`)
+const getFileInfo = async function(path, userId) {
+ const url = generateRemoteUrl(`dav/files/${userId}/${path}`)
const res = await axios({
method: 'PROPFIND',
url,
@@ -156,6 +185,7 @@ const getFileInfo = async function(path, dav) {
+
`,
}).catch(() => {
diff --git a/src/services/talkService.js b/src/services/talkService.js
index f2cfe79d6701d03a535666eff1e255195ae12287..0b3e2ef0466abe231fbe33b1ceb1ae03ee37cea3 100644
--- a/src/services/talkService.js
+++ b/src/services/talkService.js
@@ -78,12 +78,14 @@ export async function updateTalkParticipants(eventComponent) {
return
}
try {
+ const { data: { ocs: { data: room } } } = await HTTPClient.get(generateOcsUrl('apps/spreed/api/' + apiVersion + '/', 2) + 'room/' + token)
const participantsResponse = await HTTPClient.get(generateOcsUrl('apps/spreed/api/' + apiVersion + '/', 2) + 'room/' + token + '/participants')
// Ignore if the actor isn't owner of the conversation
if (!participantsResponse.data.ocs.data.some(participant => participant.actorId === getCurrentUser().uid && participant.participantType <= 2)) {
logger.debug('Current user is not a moderator or owner', { currentUser: getCurrentUser().uid, conversation: participantsResponse.data.ocs.data })
return
}
+ console.info('room', room)
for (const attendee of eventComponent.getAttendeeIterator()) {
logger.debug('Processing attendee', { attendee })
@@ -91,30 +93,29 @@ export async function updateTalkParticipants(eventComponent) {
continue
}
- let participantId = removeMailtoPrefix(attendee.email)
- let attendeeSource = 'emails'
+ const participantId = removeMailtoPrefix(attendee.email)
try {
// Map attendee email to Nextcloud user uid
const searchResult = await HTTPClient.get(generateOcsUrl('core/autocomplete/', 2) + 'get?search=' + encodeURIComponent(participantId) + '&itemType=&itemId=%20&shareTypes[]=0&limit=2')
- // Only map if there is exactly one result. Use email if there are none or more results.
- if (searchResult.data.ocs.data.length === 1) {
- participantId = searchResult.data.ocs.data[0].id
- attendeeSource = 'users'
+ // Only map if there is exactly one result
+ if (searchResult.data.ocs.data.length === 1 && searchResult.data.ocs.data[0].id !== getCurrentUser().uid) {
+ await HTTPClient.post(generateOcsUrl('apps/spreed/api/' + apiVersion + '/', 2) + 'room/' + token + '/participants', {
+ newParticipant: searchResult.data.ocs.data[0].id,
+ source: 'users',
+ })
+ } else if (searchResult.data.ocs.data[0]?.id === getCurrentUser().uid) {
+ logger.debug('Skipping organizer ' + searchResult.data.ocs.data[0].id)
+ } else if (room.type === 3) {
+ await HTTPClient.post(generateOcsUrl('apps/spreed/api/' + apiVersion + '/', 2) + 'room/' + token + '/participants', {
+ newParticipant: participantId,
+ source: 'emails',
+ })
} else {
- logger.debug('Attendee ' + participantId + ' is not a Nextcloud user')
+ logger.debug('Attendee ' + participantId + ' ignored as Talk participant')
}
} catch (error) {
- logger.info('Could not find user data for attendee ' + participantId, { error })
+ logger.info('Could not add attendee ' + participantId + ' as Talk participant', { error })
}
-
- if (attendeeSource === 'users' && participantId === getCurrentUser().uid) {
- logger.debug('Skipping organizer')
- continue
- }
- await HTTPClient.post(generateOcsUrl('apps/spreed/api/' + apiVersion + '/', 2) + 'room/' + token + '/participants', {
- newParticipant: participantId,
- source: attendeeSource,
- })
}
} catch (error) {
logger.warn('Could not update Talk room attendees', { error })
diff --git a/src/store/settings.js b/src/store/settings.js
index e020cced1fab12f65aab173d5aa4a7a32cca1e76..10f02e1e2c03ca80d218457c0520e783181cc9c3 100644
--- a/src/store/settings.js
+++ b/src/store/settings.js
@@ -3,6 +3,7 @@
* @copyright Copyright (c) 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/
*
* @author Georg Ehrke
+ * @author Richard Steinmetz
*
* @license AGPL-3.0-or-later
*
@@ -27,6 +28,7 @@ import { setConfig as setCalendarJsConfig } from '@nextcloud/calendar-js'
import { setConfig } from '../services/settings.js'
import { logInfo } from '../utils/logger.js'
import getTimezoneManager from '../services/timezoneDataProviderService.js'
+import * as AttachmentService from '../services/attachmentService.js'
const state = {
// env
@@ -51,6 +53,7 @@ const state = {
// user-defined Nextcloud settings
momentLocale: 'en',
attachmentsFolder: '/Calendar',
+ attachmentsFolderCreated: false,
}
const mutations = {
@@ -142,6 +145,18 @@ const mutations = {
*/
setAttachmentsFolder(state, { attachmentsFolder }) {
state.attachmentsFolder = attachmentsFolder
+ state.attachmentsFolderCreated = false
+ },
+
+ /**
+ * Update wheter the user's attachments folder has been created
+ *
+ * @param {object} state The Vuex state
+ * @param {object} data The destructuring object
+ * @param {boolean} data.attachmentsFolderCreated True if the folder has been created
+ */
+ setAttachmentsFolderCreated(state, { attachmentsFolderCreated }) {
+ state.attachmentsFolderCreated = attachmentsFolderCreated
},
/**
@@ -450,6 +465,30 @@ const actions = {
commit('setAttachmentsFolder', { attachmentsFolder })
},
+ /**
+ * Create the user's attachment folder if it doesn't exist and return its path
+ *
+ * @param {object} vuex The Vuex destructuring object
+ * @param {object} vuex.state The Vuex state
+ * @param {Function} vuex.commit The Vuex commit Function
+ * @param {Function} vuex.dispatch The Vuex commit function
+ * @param {object} vuex.getters The Vuex getters object
+ * @return {Promise} The path of the user's attachments folder
+ */
+ async createAttachmentsFolder({ state, commit, dispatch, getters }) {
+ if (state.attachmentsFolderCreated) {
+ return state.attachmentsFolder
+ }
+
+ const userId = getters.getCurrentUserPrincipal.dav.userId
+ const path = await AttachmentService.createFolder(state.attachmentsFolder, userId)
+ if (path !== state.attachmentsFolder) {
+ await dispatch('setAttachmentsFolder', { attachmentsFolder: path })
+ }
+ commit('setAttachmentsFolderCreated', { attachmentsFolderCreated: true })
+ return path
+ },
+
/**
* Initializes the calendar-js configuration
*
diff --git a/src/utils/localeTime.js b/src/utils/localeTime.js
index c348b0d3caef9b5a87dbf5beb841662b69437226..21b5cf14ead3a569eb8799bcccbe78ac77c93ce9 100644
--- a/src/utils/localeTime.js
+++ b/src/utils/localeTime.js
@@ -46,3 +46,16 @@ export function timeStampToLocaleTime(timeStamp, timeZoneId) {
minute: 'numeric',
})
}
+
+/**
+ * Format a time stamp as local date
+ *
+ * @param timeStamp {Number} unix times stamp in seconds
+ * @param timeZoneId {string} IANA time zone identifier
+ * @return {string} the formatted date
+ */
+export function timeStampToLocaleDate(timeStamp, timeZoneId) {
+ return (new Date(timeStamp * 1000)).toLocaleDateString(locale, {
+ timeZone: timeZoneId,
+ })
+}
diff --git a/src/views/Appointments/Booking.vue b/src/views/Appointments/Booking.vue
index 0c7c681077791c0498ac17515433f8ed3640bd61..f2c77ad6c2e46011090ebff64563159f2f15ffe5 100644
--- a/src/views/Appointments/Booking.vue
+++ b/src/views/Appointments/Booking.vue
@@ -76,6 +76,7 @@
:visitor-info="visitorInfo"
:time-zone-id="timeZone"
:show-error="bookingError"
+ :show-rate-limiting-warning="bookingRateLimit"
:is-loading="bookingLoading"
@save="onSave"
@close="selectedSlot = undefined" />
@@ -172,6 +173,7 @@ export default {
bookingConfirmed: false,
bookingError: false,
bookingLoading: false,
+ bookingRateLimit: false,
}
},
watch: {
@@ -229,6 +231,7 @@ export default {
})
this.bookingError = false
+ this.bookingRateLimit = false
try {
await bookSlot(this.config, slot, displayName, email, description, timeZone)
@@ -238,7 +241,11 @@ export default {
this.bookingConfirmed = true
} catch (e) {
console.error('could not book appointment', e)
- this.bookingError = true
+ if (e?.response?.status === 429) {
+ this.bookingRateLimit = true
+ } else {
+ this.bookingError = true
+ }
} finally {
this.bookingLoading = false
}
diff --git a/src/views/EditSimple.vue b/src/views/EditSimple.vue
index b16529255f057fb228e74841be17899b22160090..b50c4191d88d21eb77e603ae9aeb29df0b1a9440 100644
--- a/src/views/EditSimple.vue
+++ b/src/views/EditSimple.vue
@@ -139,7 +139,6 @@
:value="location"
:linkify-links="true"
@update:value="updateLocation" />
-
{}
diff --git a/tests/javascript/unit/store/settings.test.js b/tests/javascript/unit/store/settings.test.js
index 0a462e12cb2220d48a6d78b0ec43a82e83facac5..0105ad0701992db3204dd0743a0cd7b9b2ec403d 100644
--- a/tests/javascript/unit/store/settings.test.js
+++ b/tests/javascript/unit/store/settings.test.js
@@ -66,6 +66,7 @@ describe('store/settings test suite', () => {
disableAppointments: false,
canSubscribeLink: true,
attachmentsFolder: '/Calendar',
+ attachmentsFolderCreated: false,
showResources: true,
})
})
diff --git a/tests/php/unit/Service/Appointments/AvailabilityGeneratorTest.php b/tests/php/unit/Service/Appointments/AvailabilityGeneratorTest.php
index bd4107f782e91018c0b3450d8498d3a94a984758..be61cb7fe6ad636be1a5ecb022da0eda1317b17a 100644
--- a/tests/php/unit/Service/Appointments/AvailabilityGeneratorTest.php
+++ b/tests/php/unit/Service/Appointments/AvailabilityGeneratorTest.php
@@ -100,30 +100,158 @@ class AvailabilityGeneratorTest extends TestCase {
);
}
- public function testNoAvailabilitySetRoundWithIncrement(): void {
+ public function testNoAvailabilitySetRoundWithRealLifeTimes(): void {
$config = new AppointmentConfig();
- $config->setLength(5400);
+ $config->setLength(900);
$config->setIncrement(3600);
$config->setAvailability(null);
- $slots = $this->generator->generate($config, 1 * 5400, 2 * 5400);
+ $sdate = new \DateTime('2024-03-04 10:00');
+ $edate = new \DateTime('2024-03-04 10:15');
+
+ $slots = $this->generator->generate($config, $sdate->getTimestamp(), $edate->getTimestamp());
+
+ self::assertCount(1, $slots);
+ self::assertEquals(0, $slots[0]->getStart() % 3600);
+ self::assertEquals(900, ($slots[0]->getEnd() - $slots[0]->getStart()));
+ self::assertEquals(
+ [new Interval(1709546400, 1709547300)],
+ $slots,
+ );
+ }
+
+ public function testNoAvailabilityQuarterHourIncrementsFourtyFiveMinutesLenght(): void {
+ $config = new AppointmentConfig();
+ $config->setLength(2700); // 45 minutes
+ $config->setIncrement(900); // 15 minutes
+ $config->setAvailability(null);
+
+ $this->timeFactory->expects(self::once())
+ ->method('getTime')
+ ->willReturn(1709546400);
+
+ $sdate = new \DateTime('2024-03-04 10:00');
+ $edate = new \DateTime('2024-03-04 10:45');
+
+ $slots = $this->generator->generate($config, $sdate->getTimestamp(), $edate->getTimestamp());
+
+ self::assertCount(1, $slots);
+ self::assertEquals(0, ($slots[0]->getStart() % $config->getIncrement()));
+ self::assertEquals(45 * 60, ($slots[0]->getEnd() - $slots[0]->getStart()));
+ self::assertEquals(
+ [new Interval(1709546400, 1709549100)],
+ $slots,
+ );
+ }
+
+ public function testNoAvailabilityZeroTo2700QuarterHourIncrementsFourtyFiveMinutesLenght(): void {
+ $config = new AppointmentConfig();
+ $config->setLength(2700); // 45 minutes
+ $config->setIncrement(900); // 15 minutes
+ $config->setAvailability(null);
+
+ $slots = $this->generator->generate($config, 0, 2700);
+
+ self::assertCount(1, $slots);
+ self::assertEquals(0, $slots[0]->getStart() % 2700);
+ self::assertEquals(45 * 60, ($slots[0]->getEnd() - $slots[0]->getStart()));
+ self::assertEquals(
+ [new Interval(0, 45 * 60)],
+ $slots,
+ );
+ }
+
+
+ public function testNoAvailabilitySetRoundWithRealLifeTimesUgly(): void {
+ $config = new AppointmentConfig();
+ $config->setLength(900);
+ $config->setIncrement(3600);
+ $config->setAvailability(null);
+
+ $sdate = new \DateTime('2024-03-04 09:50');
+ $edate = new \DateTime('2024-03-04 10:15');
+
+ $slots = $this->generator->generate($config, $sdate->getTimestamp(), $edate->getTimestamp());
+
+ self::assertCount(1, $slots);
+ self::assertEquals(0, $slots[0]->getStart() % 3600);
+ self::assertEquals(900, ($slots[0]->getEnd() - $slots[0]->getStart()));
+ self::assertEquals(
+ [new Interval(1709546400, 1709547300)],
+ $slots,
+ );
+ }
+
+ public function testNoAvailabilitySetRoundWithRealLifeTimesUglyTwo(): void {
+ $config = new AppointmentConfig();
+ $config->setLength(900);
+ $config->setIncrement(3600);
+ $config->setAvailability(null);
+
+ $sdate = new \DateTime('2024-03-04 09:01');
+ $edate = new \DateTime('2024-03-04 10:15');
+
+ $slots = $this->generator->generate($config, $sdate->getTimestamp(), $edate->getTimestamp());
+
+ self::assertCount(1, $slots);
+ self::assertEquals(0, $slots[0]->getStart() % 3600);
+ self::assertEquals(900, ($slots[0]->getEnd() - $slots[0]->getStart()));
+ self::assertEquals(
+ [new Interval(1709546400, 1709547300)],
+ $slots,
+ );
+ }
+
+ public function testNoAvailabilitySetRoundWithIncrementForHalfHour(): void {
+ $config = new AppointmentConfig();
+ $config->setLength(3600);
+ $config->setIncrement(3600);
+ $config->setAvailability(null);
+
+ $slots = $this->generator->generate($config, 1 * 1800, 3 * 3600);
+
+ self::assertCount(1, $slots);
+ self::assertEquals(0, $slots[0]->getStart() % 3600);
+ self::assertEquals(3600, $slots[0]->getStart());
+ }
+
+ public function testNoAvailabilitySetRoundWithIncrementForTwoHours(): void {
+ $config = new AppointmentConfig();
+ $config->setLength(3600);
+ $config->setIncrement(7200);
+ $config->setAvailability(null);
+
+ $slots = $this->generator->generate($config, 1 * 1800, 3 * 3600);
self::assertCount(1, $slots);
self::assertEquals(0, $slots[0]->getStart() % 3600);
self::assertEquals(7200, $slots[0]->getStart());
}
+ public function testNoAvailabilitySetRoundWithIncrementForFullHour(): void {
+ $config = new AppointmentConfig();
+ $config->setLength(3600);
+ $config->setIncrement(3600);
+ $config->setAvailability(null);
+
+ $slots = $this->generator->generate($config, 1 * 3600, 2 * 3600);
+
+ self::assertCount(1, $slots);
+ self::assertEquals(0, $slots[0]->getStart() % 3600);
+ self::assertEquals(3600, $slots[0]->getStart());
+ }
+
public function testNoAvailabilitySetRoundToPrettyNumbers(): void {
$config = new AppointmentConfig();
- $config->setLength(5400);
+ $config->setLength(3550);
$config->setIncrement(300);
$config->setAvailability(null);
- $slots = $this->generator->generate($config, 1 * 5400 + 1, 2 * 5400 + 1);
+ $slots = $this->generator->generate($config, 1 * 3550 + 1, 2 * 3550 + 1);
self::assertCount(1, $slots);
self::assertEquals(0, $slots[0]->getStart() % 300);
- self::assertEquals(5700, $slots[0]->getStart());
+ self::assertEquals(3600, $slots[0]->getStart());
}
public function testNoAvailabilitySetRoundWithFourtyMinutes(): void {
@@ -240,6 +368,180 @@ class AvailabilityGeneratorTest extends TestCase {
self::assertCount(1, $slots);
}
+ public function testSimpleRuleUgly(): void {
+ $dateTime = new DateTimeImmutable();
+ $tz = new DateTimeZone('Europe/Vienna');
+ $startTimestamp = $dateTime
+ ->setTimezone($tz)
+ ->setDate(2021, 11, 22)
+ ->setTime(8, 10)->getTimestamp();
+ $endTimestamp = $dateTime
+ ->setTimezone($tz)
+ ->setTime(17, 0)->getTimestamp();
+ $config = new AppointmentConfig();
+ $config->setLength(900);
+ $config->setIncrement(3600);
+ $config->setAvailability(json_encode([
+ 'timezoneId' => $tz->getName(),
+ 'slots' => [
+ 'MO' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'TU' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'WE' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'TH' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'FR' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'SA' => [],
+ 'SU' => []
+ ]
+ ], JSON_THROW_ON_ERROR));
+ $start = (new DateTimeImmutable())->setDate(2021, 11, 3)->setTime(9, 0);
+ $end = $start->modify('+15 minutes');
+
+ $slots = $this->generator->generate($config, $start->getTimestamp(), $end->getTimestamp());
+
+ self::assertCount(1, $slots);
+ }
+
+ public function testSimpleRuleUglyTwo(): void {
+ $dateTime = new DateTimeImmutable();
+ $tz = new DateTimeZone('Europe/Vienna');
+ $startTimestamp = $dateTime
+ ->setTimezone($tz)
+ ->setDate(2021, 11, 22)
+ ->setTime(8, 10)->getTimestamp();
+ $endTimestamp = $dateTime
+ ->setTimezone($tz)
+ ->setTime(17, 0)->getTimestamp();
+ $config = new AppointmentConfig();
+ $config->setLength(3600);
+ $config->setIncrement(900);
+ $config->setAvailability(json_encode([
+ 'timezoneId' => $tz->getName(),
+ 'slots' => [
+ 'MO' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'TU' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'WE' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'TH' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'FR' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'SA' => [],
+ 'SU' => []
+ ]
+ ], JSON_THROW_ON_ERROR));
+ $start = (new DateTimeImmutable())->setDate(2021, 11, 3)->setTime(9, 15);
+ $end = $start->modify('+1 hour');
+
+ $slots = $this->generator->generate($config, $start->getTimestamp(), $end->getTimestamp());
+
+ self::assertCount(1, $slots);
+ }
+
+ public function testSimpleRuleUglyEqual(): void {
+ $dateTime = new DateTimeImmutable();
+ $tz = new DateTimeZone('Europe/Vienna');
+ $startTimestamp = $dateTime
+ ->setTimezone($tz)
+ ->setDate(2021, 11, 22)
+ ->setTime(8, 10)->getTimestamp();
+ $endTimestamp = $dateTime
+ ->setTimezone($tz)
+ ->setTime(17, 0)->getTimestamp();
+ $config = new AppointmentConfig();
+ $config->setLength(900);
+ $config->setIncrement(900);
+ $config->setAvailability(json_encode([
+ 'timezoneId' => $tz->getName(),
+ 'slots' => [
+ 'MO' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'TU' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'WE' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'TH' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'FR' => [
+ [
+ 'start' => $startTimestamp,
+ 'end' => $endTimestamp,
+ ]
+ ],
+ 'SA' => [],
+ 'SU' => []
+ ]
+ ], JSON_THROW_ON_ERROR));
+ $start = (new DateTimeImmutable())->setDate(2021, 11, 3)->setTime(9, 15);
+ $end = $start->modify('+15 minutes');
+
+ $slots = $this->generator->generate($config, $start->getTimestamp(), $end->getTimestamp());
+
+ self::assertCount(1, $slots);
+ }
+
public function testViennaComplexRule(): void {
$tz = new DateTimeZone('Europe/Vienna');
$dateTime = (new DateTimeImmutable())->setTimezone($tz)->setDate(2021, 11, 22);
@@ -610,4 +912,64 @@ class AvailabilityGeneratorTest extends TestCase {
$slots = $this->generator->generate($config, $wednesdayMidnight->getTimestamp(), $thursdayMidnight->getTimestamp());
self::assertCount(0, $slots);
}
+
+ public function testViennaComplexRuleForBooking(): void {
+ $tz = new DateTimeZone('Europe/Vienna');
+ $dateTime = (new DateTimeImmutable())->setTimezone($tz)->setDate(2021, 11, 22);
+ $config = new AppointmentConfig();
+ $config->setLength(3600);
+ $config->setIncrement(3600);
+ $config->setAvailability(json_encode([
+ 'timezoneId' => $tz->getName(),
+ 'slots' => [
+ 'MO' => [
+ [
+ 'start' => $dateTime->setTime(8, 0)->getTimestamp(),
+ 'end' => $dateTime->setTime(12, 0)->getTimestamp(),
+ ],
+ [
+ 'start' => $dateTime->setTime(14, 0)->getTimestamp(),
+ 'end' => $dateTime->setTime(18, 0)->getTimestamp(),
+ ]
+ ],
+ 'TU' => [
+ [
+ 'start' => $dateTime->setTime(8, 30)->getTimestamp(),
+ 'end' => $dateTime->setTime(11, 45)->getTimestamp(),
+ ]
+ ],
+ 'WE' => [
+ [
+ 'start' => $dateTime->setTime(13, 10)->getTimestamp(),
+ 'end' => $dateTime->setTime(16, 0)->getTimestamp(),
+ ]
+ ],
+ 'TH' => [
+ [
+ 'start' => $dateTime->setTime(19, 0)->getTimestamp(),
+ 'end' => $dateTime->setTime(23, 59)->getTimestamp(),
+ ]
+ ],
+ 'FR' => [
+ [
+ 'start' => $dateTime->setTime(6, 0)->getTimestamp(),
+ 'end' => $dateTime->setTime(8, 0)->getTimestamp(),
+ ]
+ ],
+ 'SA' => [
+ [
+ 'start' => $dateTime->setTime(1, 52)->getTimestamp(),
+ 'end' => $dateTime->setTime(17, 0)->getTimestamp(),
+ ]
+ ],
+ 'SU' => [],
+ ]
+ ], JSON_THROW_ON_ERROR));
+ $mondayMidnight = (new DateTimeImmutable())->setDate(2021, 11, 13)->setTime(13, 10);
+ $sundayMidnight = $mondayMidnight->modify('+1 hour');
+
+ $slots = $this->generator->generate($config, $mondayMidnight->getTimestamp(), $sundayMidnight->getTimestamp());
+
+ self::assertCount(1, $slots);
+ }
}
diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml
index 7655a6ae66833d4e1db58e533f04539cf29b85cc..337c6cd8ab17633d814bd8bbdf4169ef1dc92bc4 100644
--- a/tests/psalm-baseline.xml
+++ b/tests/psalm-baseline.xml
@@ -1,5 +1,5 @@
-
+
CalendarWidgetV2
@@ -13,6 +13,15 @@
$expectedDayKeys !== $actualDayKeys
+
+ UserRateLimit
+
+
+
+
+ AnonRateLimit
+ UserRateLimit
+
diff --git a/vendor-bin/cs-fixer/composer.json b/vendor-bin/cs-fixer/composer.json
index d1f356dcfc018d74be8a01289c4b1a417c9a4360..cb6559e5fa7988b95a29a09ec1d55dfedcbbd7a2 100644
--- a/vendor-bin/cs-fixer/composer.json
+++ b/vendor-bin/cs-fixer/composer.json
@@ -8,4 +8,4 @@
"require-dev": {
"nextcloud/coding-standard": "^1.1.1"
}
-}
\ No newline at end of file
+}
diff --git a/vendor-bin/cs-fixer/composer.lock b/vendor-bin/cs-fixer/composer.lock
index 2a35a67b65d9efff12c3608e6c215073d0cc14b2..2a84d1eaeb762a478447e55d5dc67c886ce277c1 100644
--- a/vendor-bin/cs-fixer/composer.lock
+++ b/vendor-bin/cs-fixer/composer.lock
@@ -112,4 +112,4 @@
"php": "7.4"
},
"plugin-api-version": "2.3.0"
-}
\ No newline at end of file
+}
diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json
index 6860efcec5554f0cf47015e5d1f888e83839e537..369119095d8c839fd72b8adf5c0331b9bcc17543 100644
--- a/vendor-bin/phpunit/composer.json
+++ b/vendor-bin/phpunit/composer.json
@@ -8,4 +8,4 @@
"require-dev": {
"christophwurst/nextcloud_testing": "^1.0.0"
}
-}
\ No newline at end of file
+}
diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock
index 82c78edd4b31361ebb852ff135106f18cbd721df..5cd025bb0a79599a94fdf21d906783740275c59f 100644
--- a/vendor-bin/phpunit/composer.lock
+++ b/vendor-bin/phpunit/composer.lock
@@ -2087,4 +2087,4 @@
"php": "7.4"
},
"plugin-api-version": "2.6.0"
-}
\ No newline at end of file
+}