summaryrefslogtreecommitdiffstats
path: root/apps/dav/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/src')
-rw-r--r--apps/dav/src/dav/client.js39
-rw-r--r--apps/dav/src/service/CalendarService.js147
-rw-r--r--apps/dav/src/service/logger.js28
-rw-r--r--apps/dav/src/settings-personal-availability.js9
-rw-r--r--apps/dav/src/views/Availability.vue223
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>