diff options
Diffstat (limited to 'apps/dav/src')
-rw-r--r-- | apps/dav/src/dav/client.js | 39 | ||||
-rw-r--r-- | apps/dav/src/service/CalendarService.js | 147 | ||||
-rw-r--r-- | apps/dav/src/service/logger.js | 28 | ||||
-rw-r--r-- | apps/dav/src/settings-personal-availability.js | 9 | ||||
-rw-r--r-- | apps/dav/src/views/Availability.vue | 223 |
5 files changed, 446 insertions, 0 deletions
diff --git a/apps/dav/src/dav/client.js b/apps/dav/src/dav/client.js new file mode 100644 index 00000000000..cd521ad80c8 --- /dev/null +++ b/apps/dav/src/dav/client.js @@ -0,0 +1,39 @@ +/* + * @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 * as webdav from 'webdav' +import axios from '@nextcloud/axios' +import memoize from 'lodash/fp/memoize' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' + +export const getClient = memoize((service) => { + // Add this so the server knows it is an request from the browser + axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest' + + // force our axios + const patcher = webdav.getPatcher() + patcher.patch('request', axios) + + return webdav.createClient( + generateRemoteUrl(`dav/${service}/${getCurrentUser().uid}`) + ) +}) diff --git a/apps/dav/src/service/CalendarService.js b/apps/dav/src/service/CalendarService.js new file mode 100644 index 00000000000..ff47be59959 --- /dev/null +++ b/apps/dav/src/service/CalendarService.js @@ -0,0 +1,147 @@ +/** + * @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 { getClient } from '../dav/client' +import ICAL from 'ical.js' +import logger from './logger' +import { parseXML } from 'webdav/dist/node/tools/dav' +import { v4 as uuidv4 } from 'uuid' + +export function getEmptySlots() { + return { + MO: [], + TU: [], + WE: [], + TH: [], + FR: [], + SA: [], + SU: [], + } +} + +export async function findScheduleInboxAvailability() { + const client = getClient('calendars') + + const response = await client.customRequest('inbox', { + method: 'PROPFIND', + data: `<?xml version="1.0"?> + <x0:propfind xmlns:x0="DAV:"> + <x0:prop> + <x1:calendar-availability xmlns:x1="urn:ietf:params:xml:ns:caldav"/> + </x0:prop> + </x0:propfind>` + }) + + const xml = await parseXML(response.data) + + if (!xml) { + return undefined + } + + const availability = xml?.multistatus?.response[0]?.propstat?.prop['calendar-availability'] + if (!availability) { + return undefined + } + + const parsedIcal = ICAL.parse(availability) + + const vcalendarComp = new ICAL.Component(parsedIcal) + const vavailabilityComp = vcalendarComp.getFirstSubcomponent('vavailability') + const availableComps = vavailabilityComp.getAllSubcomponents('available') + + // Combine all AVAILABLE blocks into a week of slots + const slots = getEmptySlots() + availableComps.forEach((availableComp) => { + const start = availableComp.getFirstProperty('dtstart').getFirstValue().toJSDate() + const end = availableComp.getFirstProperty('dtend').getFirstValue().toJSDate() + const rrule = availableComp.getFirstProperty('rrule') + + if (rrule.getFirstValue().freq !== 'WEEKLY') { + logger.warn('rrule not supported', { + rrule: rrule.toICALString(), + }) + return + } + + rrule.getFirstValue().getComponent('BYDAY').forEach(day => { + slots[day].push({ + start, + end, + }) + }) + }) + + return { + slots, + } +} + +export async function saveScheduleInboxAvailability(slots, timezoneId) { + const all = [...Object.keys(slots).flatMap(dayId => slots[dayId].map(slot => ({ + ...slot, + day: dayId, + })))] + + const vavailabilityComp = new ICAL.Component('vavailability') + // TODO: deduplicate slots that occur on more than one day + all.map(slot => { + const availableComp = new ICAL.Component('available') + + // Define DTSTART and DTEND + // TODO: tz? moment.tz(dateTime, timezone).toDate() + const startTimeProp = availableComp.addPropertyWithValue('dtstart', ICAL.Time.fromJSDate(slot.start, false)) + startTimeProp.setParameter('tzid', timezoneId) + const endTimeProp = availableComp.addPropertyWithValue('dtend', ICAL.Time.fromJSDate(slot.end, false)) + endTimeProp.setParameter('tzid', timezoneId) + + // Add mandatory UID + availableComp.addPropertyWithValue('uid', uuidv4()) + + // TODO: add optional summary + + // Define RRULE + availableComp.addPropertyWithValue('rrule', { + freq: 'WEEKLY', + byday: slot.day, + }) + + return availableComp + }).map(vavailabilityComp.addSubcomponent.bind(vavailabilityComp)) + + const vcalendarComp = new ICAL.Component('vcalendar') + vcalendarComp.addSubcomponent(vavailabilityComp) + logger.debug('New availability ical created', { + asObject: vcalendarComp, + asString: vcalendarComp.toString(), + }) + + const client = getClient('calendars') + await client.customRequest('inbox', { + method: 'PROPPATCH', + data: `<?xml version="1.0"?> + <x0:propertyupdate xmlns:x0="DAV:"> + <x0:set> + <x0:prop> + <x1:calendar-availability xmlns:x1="urn:ietf:params:xml:ns:caldav">${vcalendarComp.toString()}</x1:calendar-availability> + </x0:prop> + </x0:set> + </x0:propertyupdate>` + }) +} diff --git a/apps/dav/src/service/logger.js b/apps/dav/src/service/logger.js new file mode 100644 index 00000000000..8941d8d8adc --- /dev/null +++ b/apps/dav/src/service/logger.js @@ -0,0 +1,28 @@ +/* + * @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 { getLoggerBuilder } from '@nextcloud/logger' + +const logger = getLoggerBuilder() + .setApp('dav') + .detectUser() + .build() + +export default logger diff --git a/apps/dav/src/settings-personal-availability.js b/apps/dav/src/settings-personal-availability.js new file mode 100644 index 00000000000..b0d6b19aa8a --- /dev/null +++ b/apps/dav/src/settings-personal-availability.js @@ -0,0 +1,9 @@ +import Vue from 'vue' +import { translate } from '@nextcloud/l10n' +import Availability from './views/Availability' + +Vue.prototype.$t = translate + +const View = Vue.extend(Availability); + +(new View({})).$mount('#settings-personal-availability') diff --git a/apps/dav/src/views/Availability.vue b/apps/dav/src/views/Availability.vue new file mode 100644 index 00000000000..03f646028a8 --- /dev/null +++ b/apps/dav/src/views/Availability.vue @@ -0,0 +1,223 @@ +<template> + <div class="section"> + <h2>{{ $t('dav', 'Availability') }}</h2> + <p> + {{ $t('dav', 'If you configure your working hours, other users will see when you are out of office when they book a meeting.') }} + </p> + <div class="time-zone"> + <strong> + {{ $t('calendar', 'Please select a time zone:') }} + </strong> + <TimezonePicker v-model="timezone" /> + </div> + <div class="grid-table"> + <template v-for="day in daysOfTheWeek"> + <div :key="`day-label-${day.id}`" class="label-weekday"> + {{ day.displayName }} + </div> + <div :key="`day-slots-${day.id}`" class="availability-slots"> + <div class="availability-slot"> + <template v-for="(slot, idx) in day.slots"> + <div :key="`slot-${day.id}-${idx}`"> + <DatetimePicker + v-model="slot.start" + type="time" + class="start-date" + format="H:mm" /> + {{ $t('dav', 'to') }} + <DatetimePicker + v-model="slot.end" + type="time" + class="end-date" + format="H:mm" /> + <button :key="`slot-${day.id}-${idx}-btn`" + class="icon-delete delete-slot button" + :title="$t('dav', 'Delete slot')" + @click="deleteSlot(day, idx)" /> + </div> + </template> + </div> + <button :disabled="loading" + class="add-another button" + @click="addSlot(day)"> + {{ $t('dav', 'Add slot') }} + </button> + </div> + </template> + </div> + <button :disabled="loading || saving" + class="button" + @click="save"> + {{ $t('dav', 'Save') }} + </button> + </div> +</template> + +<script> +import DatetimePicker from '@nextcloud/vue/dist/Components/DatetimePicker' +import { + findScheduleInboxAvailability, + getEmptySlots, + saveScheduleInboxAvailability, +} from '../service/CalendarService' +import { getFirstDay } from '@nextcloud/l10n' +import jstz from 'jstimezonedetect' +import TimezonePicker from '@nextcloud/vue/dist/Components/TimezonePicker' +export default { + name: 'Availability', + components: { + DatetimePicker, + TimezonePicker, + }, + data() { + // Try to determine the current timezone, and fall back to UTC otherwise + const defaultTimezone = jstz.determine() + const defaultTimezoneId = defaultTimezone ? defaultTimezone.name() : 'UTC' + + const moToSa = [ + { + id: 'MO', + displayName: this.$t('dav', 'Monday'), + slots: [], + }, + { + id: 'TU', + displayName: this.$t('dav', 'Tuesday'), + slots: [], + }, + { + id: 'WE', + displayName: this.$t('dav', 'Wednesday'), + slots: [], + }, + { + id: 'TH', + displayName: this.$t('dav', 'Thursday'), + slots: [], + }, + { + id: 'FR', + displayName: this.$t('dav', 'Friday'), + slots: [], + }, + { + id: 'SA', + displayName: this.$t('dav', 'Saturday'), + slots: [], + }, + ] + const sunday = { + id: 'SU', + displayName: this.$t('dav', 'Sunday'), + slots: [], + } + const daysOfTheWeek = getFirstDay() === 1 ? [...moToSa, sunday] : [sunday, ...moToSa] + return { + loading: true, + saving: false, + timezone: defaultTimezoneId, + daysOfTheWeek, + } + }, + async mounted() { + try { + const { slots } = await findScheduleInboxAvailability() + if (slots) { + this.daysOfTheWeek.forEach(day => { + day.slots.push(...slots[day.id]) + }) + } + console.info('availability loaded', this.daysOfTheWeek) + } catch (e) { + console.error('could not load existing availability', e) + + // TODO: show a nice toast + } finally { + this.loading = false + } + }, + methods: { + addSlot(day) { + const start = new Date() + start.setHours(9) + start.setMinutes(0) + start.setSeconds(0) + const end = new Date() + end.setHours(17) + end.setMinutes(0) + end.setSeconds(0) + day.slots.push({ + start, + end, + }) + }, + deleteSlot(day, idx) { + day.slots.splice(idx, 1) + }, + async save() { + try { + this.saving = true + + const slots = getEmptySlots() + this.daysOfTheWeek.forEach(day => { + day.slots.forEach(slot => slots[day.id].push(slot)) + }) + await saveScheduleInboxAvailability(slots, this.timezone) + + // TODO: show a nice toast + } catch (e) { + console.error('could not save availability', e) + + // TODO: show a nice toast + } finally { + this.saving = false + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.availability-day { + padding: 0 10px 10px 10px; + position: absolute; +} +.availability-slots { + display: flex; +} +.availability-slot { + display: flex; + flex-direction: column; +} +::v-deep .mx-input-wrapper { + width: 85px; +} +::v-deep .mx-datepicker { + width: 110px; +} +::v-deep .multiselect { + border: 1px solid var(--color-border-dark); + width: 120px; +} +.time-zone { + padding: 12px 12px 12px 0; +} +.grid-table { + display: grid; + grid-template-columns: min-content auto; +} +.button { + align-self: flex-end; +} +.label-weekday { + padding: 8px 23px 14px 0; + position: relative; + display: inline-flex; +} +.delete-slot { + background-color: transparent; + border: none; + padding: 15px; +} + +</style> |