Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/AppNavigation/CalendarList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const delegatedGroups = computed<DelegatedGroup[]>(() => {
groups.set(delegatorUrl, {
delegatorUrl,
displayname: principal?.displayname || principal?.userId || '',
readOnly: !!calendar.readOnly,
readOnly: !calendar.canCreateObject && !calendar.canModifyObject,
calendars: [],
})
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/AppNavigation/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export default {

defaultCalendarOptions() {
return this.calendarsStore.calendars
.filter((calendar) => !calendar.readOnly
.filter((calendar) => (calendar.canCreateObject || calendar.canModifyObject)
&& !calendar.isSharedWithMe
&& calendar.supportsEvents)
},
Expand Down
2 changes: 1 addition & 1 deletion src/fullcalendar/eventSources/eventSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default function() {
},
}

if (calendar.readOnly) {
if (!calendar.canCreateObject && !calendar.canModifyObject) {
source.editable = false
}

Expand Down
5 changes: 3 additions & 2 deletions src/fullcalendar/interaction/eventClick.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import useSettingsStore from '@/store/settings.js'
import useWidgetStore from '@/store/widget.js'
import {
getPrefixedRoute,
isPublicOrEmbeddedRoute,
getViewMode,
ViewMode,
} from '@/utils/router.js'

/**
Expand Down Expand Up @@ -99,7 +100,7 @@ function handleEventClick(event, router, route, window, isWidget = false) {
function handleToDoClick(event, route, window, isWidget = false) {
const settingsStore = useSettingsStore()

if (isWidget || isPublicOrEmbeddedRoute(route.name)) {
if (getViewMode(route.name, isWidget) !== ViewMode.USER) {
return
}

Expand Down
40 changes: 34 additions & 6 deletions src/mixins/EditorMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { removeMailtoPrefix } from '@/utils/attendee.js'
import { uidToHexColor } from '@/utils/color.js'
import { dateFactory } from '@/utils/date.js'
import logger from '@/utils/logger.js'
import { getPrefixedRoute } from '@/utils/router.js'
import { getPrefixedRoute, getViewMode, ViewMode } from '@/utils/router.js'

/**
* This is a mixin for the editor. It contains common Vue stuff, that is
Expand Down Expand Up @@ -293,6 +293,21 @@ export default {
}
return this.principalsStore.getPrincipalByUrl(this.selectedCalendar.delegatorUrl)?.userId ?? null
},
/**
* Returns the mode the editor is currently rendered in
* (authenticated user, public share, embedded share, or widget).
*
* @return {string} One of ViewMode
*/
viewMode() {
return getViewMode(this.$route?.name, this.isWidget)
},
/**
* @return {boolean}
*/
canDuplicate() {
return this.viewMode === ViewMode.USER
},
/**
* Returns whether or not the user is allowed to delete this event
*
Expand All @@ -302,10 +317,11 @@ export default {
if (!this.calendarObject) {
return false
}
if (this.isReadOnly) {
if (this.isLoading) {
return false
}
if (this.isLoading) {
const calendar = this.calendarsStore.getCalendarById(this.calendarObject.calendarId)
if (!calendar?.canDeleteObject) {
return false
}

Expand Down Expand Up @@ -645,7 +661,7 @@ export default {
keyboardDuplicateEvent(event) {
if (event.key === 'd' && event.ctrlKey === true) {
event.preventDefault()
if (!this.isNew && !this.isReadOnly && !this.canCreateRecurrenceException) {
if (!this.isNew && this.canDuplicate) {
this.duplicateEvent()
}
}
Expand Down Expand Up @@ -701,12 +717,24 @@ export default {
},

/**
* Duplicates a calendar-object and saves it
* Duplicates the calendar-object. If the source calendar is
* read-only, the duplicate is created in the first writable calendar.
*
* @return {Promise<void>}
*/
async duplicateEvent() {
await this.calendarObjectInstanceStore.duplicateCalendarObjectInstance()
if (!this.canDuplicate) {
return
}

const calendarId = this.isReadOnly
? (this.calendarsStore.sortedCalendars[0]?.id ?? null)
: (this.calendarObject?.calendarId ?? null)
await this.calendarObjectInstanceStore.duplicateCalendarObjectInstance({ calendarId })

// The editor's calendar picker is driven by this.calendarId, which is
// separate from the store's calendarObject.calendarId.
this.calendarId = this.calendarObject?.calendarId ?? null
},

/**
Expand Down
4 changes: 0 additions & 4 deletions src/models/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ function getDefaultCalendarObject(props = {}) {
publishURL: null,
// Internal CalDAV url of this calendar
url: '',
// Whether this calendar is read-only
readOnly: false,
// The order of this calendar in the calendar-list
order: 0,
// Whether or not the calendar is shared with me
Expand Down Expand Up @@ -99,7 +97,6 @@ function mapDavCollectionToCalendar(calendar, currentUserPrincipal) {
const supportsJournals = calendar.components.includes('VJOURNAL')
const supportsTasks = calendar.components.includes('VTODO')
const owner = calendar.owner
const readOnly = !calendar.isWriteable()
const canBeShared = calendar.isShareable()
const canBePublished = calendar.isPublishable()
const canCreateObject = calendar.currentUserPrivilegeSet.includes('{DAV:}bind') || calendar.currentUserPrivilegeSet.includes('{DAV:}write') || calendar.currentUserPrivilegeSet.includes('{DAV:}all') === true
Expand Down Expand Up @@ -160,7 +157,6 @@ function mapDavCollectionToCalendar(calendar, currentUserPrincipal) {
supportsTasks,
isSharedWithMe,
owner,
readOnly,
publishURL,
canBeShared,
canBePublished,
Expand Down
50 changes: 38 additions & 12 deletions src/models/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,6 @@ function mapEventComponentToEventObject(eventComponent) {
function copyCalendarObjectInstanceIntoEventComponent(eventObject, eventComponent) {
const sourceEventComponent = eventObject.eventComponent

const unexpectedRecurrenceProperties = new Set([
'RRULE',
'EXRULE',
'RDATE',
'EXDATE',
])
for (const property of sourceEventComponent.getPropertyIterator()) {
if (unexpectedRecurrenceProperties.has(property.name)) {
throw new Error(`Illegal argument: Event objects has recurrence related property ${property.name}.`)
}
}

const propertiesExcludedFromCopying = new Set([
// These properties are regenerated for the new copy.
'UID',
Expand All @@ -213,11 +201,49 @@ function copyCalendarObjectInstanceIntoEventComponent(eventObject, eventComponen
// Currently only copying as exact occurrences.
// Therefore, do not preserve any RECURRENCE-ID.
'RECURRENCE-ID',
// A duplicate is always a single, standalone event. If the source is
// the master item of a recurring series (e.g. when duplicating its
// first occurrence, calendar-js returns the master item itself),
// its recurrence-defining properties must not carry over.
'RRULE',
'EXRULE',
'RDATE',
'EXDATE',
])

// Properties that must not occur more than once on a VEVENT (RFC 5545
// section 3.6.1) but that the source may legitimately carry a value for.
const propertiesReplacedWhenCopying = new Set([
'DTSTART',
'DTEND',
'DURATION',
'CLASS',
'DESCRIPTION',
'GEO',
'LOCATION',
'ORGANIZER',
'PRIORITY',
'STATUS',
'SUMMARY',
'TRANSP',
'URL',
])

// DTEND and DURATION are mutually exclusive. If the source uses DURATION,
// a pre-existing target DTEND (or vice versa) would otherwise be left
// behind alongside it, which is invalid.
if (sourceEventComponent.hasProperty('DTEND') || sourceEventComponent.hasProperty('DURATION')) {
eventComponent.deleteAllProperties('DTEND')
eventComponent.deleteAllProperties('DURATION')
}

for (const property of sourceEventComponent.getPropertyIterator()) {
if (propertiesExcludedFromCopying.has(property.name)) {
continue
}
if (propertiesReplacedWhenCopying.has(property.name)) {
eventComponent.deleteAllProperties(property.name)
}
const successful = eventComponent.addProperty(property.clone())
if (!successful) {
throw new Error(`Illegal state: Property ${property.name} could not be copied.`)
Expand Down
6 changes: 4 additions & 2 deletions src/store/calendarObjectInstance.js
Original file line number Diff line number Diff line change
Expand Up @@ -1425,9 +1425,11 @@ export default defineStore('calendarObjectInstance', {
/**
* Duplicate calendar-object-instance
*
* @param {object} data The destructuring object
* @param {string} data.calendarId The id of the calendar to duplicate the event into. Must be a writable calendar
* @return {Promise<void>}
*/
async duplicateCalendarObjectInstance() {
async duplicateCalendarObjectInstance({ calendarId }) {
const calendarObjectsStore = useCalendarObjectsStore()

const oldCalendarObjectInstance = this.calendarObjectInstance
Expand All @@ -1439,7 +1441,7 @@ export default defineStore('calendarObjectInstance', {
end: endDate.unixTime,
timezoneId: oldEventComponent.startDate.timezoneId,
isAllDay: oldEventComponent.isAllDay(),
calendarId: this.calendarObject?.calendarId ?? null,
calendarId,
})
const eventComponent = getObjectAtRecurrenceId(calendarObject, startDate.jsDate)
copyCalendarObjectInstanceIntoEventComponent(oldCalendarObjectInstance, eventComponent)
Expand Down
8 changes: 4 additions & 4 deletions src/store/calendars.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export default defineStore('calendars', {
sortedCalendars(state) {
return state.calendars
.filter((calendar) => calendar.supportsEvents)
.filter((calendar) => !calendar.readOnly)
.filter((calendar) => calendar.canCreateObject || calendar.canModifyObject)
.sort((a, b) => a.order - b.order)
},

Expand All @@ -101,7 +101,7 @@ export default defineStore('calendars', {
*/
sortedWritableCalendarsEvenWithoutSupportForEvents(state) {
return state.calendars
.filter((calendar) => !calendar.readOnly)
.filter((calendar) => calendar.canCreateObject || calendar.canModifyObject)
.sort((a, b) => a.order - b.order)
},

Expand All @@ -114,7 +114,7 @@ export default defineStore('calendars', {
ownSortedCalendars(state) {
return state.calendars
.filter((calendar) => calendar.supportsEvents)
.filter((calendar) => !calendar.readOnly)
.filter((calendar) => calendar.canCreateObject || calendar.canModifyObject)
.filter((calendar) => !calendar.isSharedWithMe)
.sort((a, b) => a.order - b.order)
},
Expand Down Expand Up @@ -186,7 +186,7 @@ export default defineStore('calendars', {
sortedSubscriptions(state) {
return state.calendars
.filter((calendar) => calendar.supportsEvents)
.filter((calendar) => calendar.readOnly)
.filter((calendar) => !(calendar.canCreateObject || calendar.canModifyObject))
.sort((a, b) => a.order - b.order)
},

Expand Down
7 changes: 4 additions & 3 deletions src/store/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ export default defineStore('delegation', () => {
* The calendars are tagged with isDelegated=true so CalendarList can show them
* in their own section.
*
* Read-only delegators' calendars are additionally marked readOnly=true so they
* are excluded from the calendar picker (which only lists writable calendars).
* Read-only delegators' calendars additionally have their write permissions
* cleared so they are excluded from the calendar picker (which only lists
* calendars with canCreateObject or canModifyObject).
*/
async function fetchDelegatedCalendars(): Promise<void> {
if (!delegators.value.length) {
Expand Down Expand Up @@ -200,7 +201,7 @@ export default defineStore('delegation', () => {
isDelegated: true,
delegatorUrl: canonicalDelegatorUrl,
// Read-only proxy access: prevent editing and hide from calendar picker
...(permission === 'read' ? { readOnly: true } : {}),
...(permission === 'read' ? { canCreateObject: false, canModifyObject: false, canDeleteObject: false } : {}),
}))

for (const calendar of mappedCalendars) {
Expand Down
2 changes: 0 additions & 2 deletions src/types/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ export interface CalendarInterface {
publishURL: string | null
/** Internal CalDAV url of this calendar */
url: string
/** Whether this calendar is read-only */
readOnly: boolean
/** The order of this calendar in the calendar-list */
order: number
/** Whether the calendar is shared with the current user */
Expand Down
41 changes: 36 additions & 5 deletions src/utils/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,42 @@ export function getPrefixedRoute(currentRouteName, toRouteName) {
}

/**
* Checks whether a routeName represents a public / embedded route
* The different modes the calendar app can be rendered in.
* This is the single source of truth for what "public", "embedded"
* and "widget" mean across the app - components should derive their
* behaviour from this instead of re-deriving it from route names or
* calendar permissions.
*/
export const ViewMode = Object.freeze({
// A normal, authenticated user viewing their own calendars
USER: 'user',
// A public share link (/p/:tokens/...)
PUBLIC: 'public',
// An embedded share link (/embed/:tokens/...)
EMBEDDED: 'embedded',
// A dashboard / Talk / Text reference widget (no vue-router present)
WIDGET: 'widget',
})

/**
* Determines the view mode the calendar is currently rendered in.
*
* @param {string} routeName Name of the route
* @return {boolean}
* @param {string|undefined|null} routeName Name of the current vue-router route, if any
* @param {boolean} isWidget Whether the calendar is rendered as a reference widget
* @return {string} One of ViewMode
*/
export function isPublicOrEmbeddedRoute(routeName) {
return routeName.startsWith('Embed') || routeName.startsWith('Public')
export function getViewMode(routeName, isWidget = false) {
if (isWidget) {
return ViewMode.WIDGET
}

if (routeName?.startsWith('Embed')) {
return ViewMode.EMBEDDED
}

if (routeName?.startsWith('Public')) {
return ViewMode.PUBLIC
}

return ViewMode.USER
}
Loading
Loading