Newer
Older
<div class="tw-flex tw-flex-col tw-h-full">
<PageHeader :title="$t('navigation.calendar')">
<b-button-group v-if="selectedShow">
<b-button :variant="view === 'day' ? 'primary' : 'secondary'" @click="view = 'day'">
{{ $t('calendar.view.day') }}
</b-button>
<b-button :variant="view === 'week' ? 'primary' : 'secondary'" @click="view = 'week'">
{{ $t('calendar.view.week') }}
</b-button>
</b-button-group>
</PageHeader>
<template v-if="!loaded.shows">
<div class="tw-text-center">
{{ $t('loading') }}
</div>
</template>
<auth-wall v-else-if="selectedShow" class="tw-flex-1 tw-flex tw-flex-col">
<b-alert
class="tw-flex-none"
:variant="conflictCount > 0 ? 'danger' : 'success'"
:show="conflictMode"
>
<div v-if="conflictMode">
<h4>{{ $t('conflictResolution.title') }}</h4>
<p
:class="{

Konrad Mohrfeldt
committed
'tw-mb-4': resolvedScheduleRRule?.count === 1,
'tw-mb-0': resolvedScheduleRRule?.count !== 1,
}"
v-html="
$t('conflictResolution.newSchedule', {
firstDate: prettyDate(resolveData.schedule.firstDate),
startTime: resolveData.schedule.startTime,
endTime: resolveData.schedule.endTime,
})
"
/>
<p

Konrad Mohrfeldt
committed
v-if="resolvedScheduleRRule && resolvedScheduleRRule.count !== 1"
v-html="
$t('conflictResolution.recurringSchedule', {

Konrad Mohrfeldt
committed
rrule: resolvedScheduleRRule.name,
lastDate: prettyDate(resolveData.schedule.lastDate),
})
"
/>
<div v-if="submitting">
<b-row>
<b-col align="center">
<img src="/assets/radio.gif" :alt="$t('loading')" />
</b-col>
</b-row>
</div>
<div v-else>
<div v-if="conflictCount > 0">
<p>{{ $t('conflictResolution.leftToResolve', { smart_count: conflictCount }) }}</p>
<b-button variant="danger" size="sm" @click="resolveCancel">
{{ $t('cancel') }}
</b-button>
<p>{{ $t('conflictResolution.noneLeftToResolve') }}</p>
<b-button variant="success" @click="resolveSubmit">
{{ $t('conflictResolution.applySolution') }}
</b-button>
<b-button variant="danger" @click="resolveCancel">
{{ $t('cancel') }}
</b-button>
</div>
</b-alert>
<server-errors class="tw-flex-none" :errors="serverErrors" />
<div class="tw-flex-1 tw-basis-full">
<KeepAlive>
<div v-if="view === 'week'" class="tw-h-full">
<FullCalendar ref="calendar" :options="calendarConfig" />
</div>
</KeepAlive>
<CalendarDayView
v-if="view === 'day'"
:selected-day="selectedDay"
@change-day="changeDay($event)"
@edit-timeslot="editTimeslot($event)"
/>
<app-modalEmissionManagerCreate
ref="appModalEmissionManagerCreate"
@conflict="enterConflictMode"
@update="loadTimeslots()"
/>
<app-modalEmissionManagerResolve
ref="appModalEmissionManagerResolve"
@resolve-conflict="resolveConflict"
/>
<app-modalEmissionManagerEdit
ref="appModalEmissionManagerEdit"
@conflict="enterConflictMode"
@update="loadTimeslots()"
</auth-wall>
</div>
</template>
<script>
import { addDays, parseISO } from 'date-fns'
import { h } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import fullCalendarTimeGridPlugin from '@fullcalendar/timegrid'
import fullCalendarInteractionPlugin from '@fullcalendar/interaction'

Richard Blechinger
committed
import modalEmissionManagerCreate from '@/components/emissions/ModalCreate.vue'
import modalEmissionManagerResolve from '@/components/emissions/ModalResolve.vue'
import modalEmissionManagerEdit from '@/components/emissions/ModalEdit.vue'
import AuthWall from '@/components/AuthWall.vue'
import prettyDate from '@/mixins/prettyDate'
import playlist from '@/mixins/playlist'
import ServerErrors from '@/components/ServerErrors.vue'
import { getISODateString } from '@/utilities'
import PageHeader from '@/components/PageHeader.vue'
import {
calculateDurationSeconds,
getClosestSlot,
getNextAvailableSlot,
sanitizeHTML,
} from '@/util'
import CalendarDayView from '@/components/CalendarDayView.vue'

Konrad Mohrfeldt
committed
import { mapStores } from 'pinia'
import { useRRuleStore } from '@/stores/rrules'

Richard Blechinger
committed
export default {
components: {
CalendarDayView,

Richard Blechinger
committed
FullCalendar,
AuthWall,
'app-modalEmissionManagerCreate': modalEmissionManagerCreate,
'app-modalEmissionManagerResolve': modalEmissionManagerResolve,
'app-modalEmissionManagerEdit': modalEmissionManagerEdit,
},

Konrad Mohrfeldt
committed
mixins: [prettyDate, playlist],

Richard Blechinger
committed
data() {
return {
view: this.$route.query.view ?? 'week',
adminUrl: `${import.meta.env.VUE_APP_BASEURI_STEERING}/admin`,

Richard Blechinger
committed
calendarSlots: [],
selectedDay: this.$route.query.day ? parseISO(this.$route.query.day) : new Date(),

Richard Blechinger
committed
// flag for when submitting resolve data
submitting: false,
// this flag signifies if we are in conflict resolution mode
conflictMode: false,
// when conflict mode is activated, this should hold the steering response
// from schedule creation, with all the conflicts and solutions
resolveData: null,
conflictCount: 0,
conflictSolutions: [],
serverErrors: [],
currentStart: this.$route.query.week ? parseISO(this.$route.query.week) : new Date(),
stopRenderWatcher: () => {
/* noop */
},

Richard Blechinger
committed
}
},
computed: {
...mapGetters({

Richard Blechinger
committed
}),

Konrad Mohrfeldt
committed
...mapStores(useRRuleStore),
resolvedScheduleRRule() {
return this.rrulesStore.itemMap.get(this.resolveData.schedule.rruleId)
},

Richard Blechinger
committed
loaded() {
return {
shows: this.$store.state.shows.loaded['shows'],
timeslots: this.$store.state.shows.loaded['timeslots'],
playlists: this.$store.state.playlists.loaded['playlists'],

Richard Blechinger
committed
}
},
/**
* this is the whole configuration for our schedule calendar, including
* simple event handlers that do not need the whole components scope
* @returns {CalendarOptions}
*/

Richard Blechinger
committed
calendarConfig() {
const selectDay = this.selectDay.bind(this)

Konrad Mohrfeldt
committed
const slotDurationMinutes = 15

Richard Blechinger
committed
return {
plugins: [fullCalendarTimeGridPlugin, fullCalendarInteractionPlugin],
initialView: 'timeGridWeek',

Richard Blechinger
committed
locale: this.$activeLocale(),
initialDate: this.currentStart,
height: '100%',
stickyHeaderDates: true,

Richard Blechinger
committed
firstDay: 1,
navLinks: true,

Richard Blechinger
committed
buttonText: {

Richard Blechinger
committed
left: 'title',
center: '',
dayHeaderFormat: { day: 'numeric', month: 'numeric', weekday: 'short' },
eventTimeFormat: { hour: 'numeric', minute: '2-digit' },
slotLabelFormat: { hour: 'numeric', minute: '2-digit' },
allDaySlot: false,
editable: false,
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
eventContent({ event, timeText }) {
// The eventContent function doesn’t quite seem to work like documented in the fullcalendar docs:
// * the slot is broken because it doesn’t receive a context object ('arg' is undefined)
// * the Preact createElement/h function that is passed as the second argument to this function
// doesn’t render anything
// * returning { html: '<i>hello</i>' } doesn’t render anything
// * returning { domNodes: [(() => { const i = document.createElement('i'); i.textContent = 'hello'; return i })()] }
// doesn’t render anything
//
// Instead, this comment [1] mentions that one should use the Vue createElement/h function.
// Surprisingly this works.
//
// It is unclear to me why all these (documented) options fail. One major difference is that we run Vue 3 in
// Vue 2 compat mode which might be a source of errors.
//
// [1]: https://github.com/fullcalendar/fullcalendar/issues/7175#issuecomment-1409519357
const { durationMinutes, title } = event.extendedProps
// don’t render any content if it would be crammed anyway
const content =
durationMinutes > slotDurationMinutes
? [
h('div', { class: 'fc-event-time' }, timeText),
h('div', { class: 'fc-event-title-container' }, [
h('div', { class: 'fc-event-title fc-sticky' }, title),
]),
]
: []
return h('div', { class: 'fc-event-main-frame' }, content)
},
eventDidMount({ el, event, timeText }) {
const { durationMinutes } = event.extendedProps
let { title } = event.extendedProps
if (durationMinutes < slotDurationMinutes) {
title = `${timeText}: ${title}`
}
// here we add a simple tooltip to every event, so that the full title
// of a show can be viewed
el.setAttribute('title', title)
},
datesSet: (view) => {
if (
this.currentStart?.toISOString?.() !== view.start.toISOString() ||
this.currentEnd?.toISOString?.() !== view.end.toISOString()
) {
this.$router.replace({
name: this.$route.name,
params: { ...this.$route.params },
query: {
...this.$route.query,
view: this.view,
day: getISODateString(this.selectedDay),
week: getISODateString(view.start),
},
this.currentStart = view.start
this.currentEnd = view.end
}

Richard Blechinger
committed
},
eventClick: this.eventSelected,
select: this.createEvent,
selectable: true,
selectMirror: true,
slotDuration: `00:${slotDurationMinutes.toString().padStart(2, '0')}:00`,

Konrad Mohrfeldt
committed
eventMinHeight: 1,
selectAllow({ start }) {

Konrad Mohrfeldt
committed
return start >= getClosestSlot(slotDurationMinutes)

Richard Blechinger
committed
navLinkDayClick(selectedDate) {
.querySelectorAll('thead .fc-day[data-date]')
.forEach((el) => el.classList.remove('fc-day-selected'))
document
.querySelector(`thead .fc-day[data-date="${getISODateString(selectedDate)}"]`)

Richard Blechinger
committed
}
},
...mapGetters({
shows: 'shows/shows',
playlists: 'playlists/playlists',

Richard Blechinger
committed
selectedShow: 'shows/selectedShow',
timeslots: 'shows/timeslots',
getPlaylistById: 'playlists/getPlaylistById',
files: 'files/files',
getFileById: 'files/getFileById',

Richard Blechinger
committed
},

Konrad Mohrfeldt
committed
watch: {
view(newView) {
void this.$router.replace({
name: this.$route.name,
params: { ...this.$route.params },
query: { ...this.$route.query, view: newView },
})
},
selectedDay(newDate) {
void this.$router.replace({
name: this.$route.name,
params: { ...this.$route.params },
query: { ...this.$route.query, day: getISODateString(newDate) },
})
},

Konrad Mohrfeldt
committed
selectedShow: {
immediate: true,
handler(newShow) {
if (newShow) {
this.$store.dispatch('playlists/fetch', { showSlug: newShow.slug })

Konrad Mohrfeldt
committed
this.loadTimeslots()

Richard Blechinger
committed
}

Konrad Mohrfeldt
committed
},
},

Richard Blechinger
committed
mounted() {
this.$nextTick(() => {
document
.querySelectorAll('.fc-day-header[data-date]')
.forEach((el) => el.classList.remove('fc-day-selected'))
document
.querySelector(`.fc-day-header[data-date="${getISODateString()}"]`)
.classList.add('fc-day-selected')
} catch (e) {
// Full Calendar might not be initialized yet.
}

Richard Blechinger
committed
})
// Watches all relevant properties for timeslot updates
// and executes the timeslot updater when a change is detected.
// Array joining in the source is done because arrays are compared by identity in JavaScript.
// Also… this would look so much nicer as a watchEffect(() => { ... })
// but would obviously require a Composition-API rewrite.
this.stopRenderWatcher = this.$watch(
(em) =>
[
em.loaded.shows,
em.loaded.playlists,
em.conflictMode,
em.currentStart?.toISOString?.(),
em.currentEnd?.toISOString?.(),
].join('-'),
() => {
// this is called when the user changes the calendar view, so we just
// re-fetch the timeslots with the updated visible date range
if (this.loaded.shows && this.loaded.playlists) {
// we only load new timeslots, if we are not in conflict mode
if (!this.conflictMode) {
}
}
},
)
},
unmounted() {
this.stopRenderWatcher()
},

Richard Blechinger
committed
methods: {
this.selectedDay = addDays(this.selectedDay, delta)

Richard Blechinger
committed
selectDay(date) {
this.selectedDay = date
},
switchShow(index) {
this.$store.commit('shows/switchShow', index)
this.loadCalendarSlots()
},
getShowTitleById(id) {
const i = this.shows.findIndex((show) => show.id === id)

Richard Blechinger
committed
if (i >= 0) {
return this.shows[i].name
} else {
return 'Error: no show found for this timeslot'
}
},
getShowIndexById(id) {
const i = this.shows.findIndex((show) => show.id === id)

Richard Blechinger
committed
if (i >= 0) {
return i
} else {
this.$log.error('No show found for id ' + id)
return 0
}
},
editTimeslot(timeslot) {
this.$refs.appModalEmissionManagerEdit.open(timeslot)

Richard Blechinger
committed
},
// this handler will be called whenever the user clicks on one of the
// displayed timeslots

Richard Blechinger
committed
// in conflict resolution mode only the newly generated events are
// clickable. if there is no conflict for an event, only a modal
// with a short notice should be opened, otherwise the resolution modal

Richard Blechinger
committed
if (this.conflictMode) {
const projectedTimeslot = event.extendedProps
if (this.conflictSolutions[projectedTimeslot.hash] === undefined) {

Richard Blechinger
committed
this.$refs.appModalEmissionManagerResolve.openNotNeeded()
} else {
this.$refs.appModalEmissionManagerResolve.open(projectedTimeslot)

Richard Blechinger
committed
}
}
// standard mode only those events are clickable that belong to the

Richard Blechinger
committed
// currently selected show.
else {
const selectedTimeslotId = event.extendedProps.id
const timeslot = this.timeslots.find((slot) => slot.id === selectedTimeslotId)
if (timeslot.showId !== this.selectedShow.id) {
this.switchShow(this.getShowIndexById(timeslot.showId))

Richard Blechinger
committed
} else {
this.$refs.appModalEmissionManagerEdit.open(timeslot)
}
}
},
// this handler is called when the user creates a new timeslot

Richard Blechinger
committed
if (!this.conflictMode) {

Konrad Mohrfeldt
committed
this.$refs.appModalEmissionManagerCreate.open(
start < new Date() ? getNextAvailableSlot(5) : start,
end,
)

Richard Blechinger
committed
}
},

Richard Blechinger
committed
this.resolveData = data
this.conflictMode = true
this.conflictCount = 0
this.conflictSolutions = data.solutions
this.calendarSlots = []
try {
for (const projectedTimeslot of data.projected) {
// we need a numeric ID for the event for later selection by the user.
// with converting the hash to a number (in this case a float), we
// do not risk using a number that is already used by a timeslot id
// of a conflicting timeslot
const id = Number(projectedTimeslot.hash)
const newSlot = {
id,
start: projectedTimeslot.start,
end: projectedTimeslot.end,
title: this.$t('conflictResolution.conflictingSlot'),

Richard Blechinger
committed
className: 'noconflict',
editable: false,
extendedProps: {
id,
// the hash is needed to compare against solutions and conflicts
hash: projectedTimeslot.hash,
start: projectedTimeslot.start,
end: projectedTimeslot.end,
collisions: [],
solutionChoices: [],
title: this.$t('conflictResolution.conflictingSlot'),
},

Richard Blechinger
committed
}
if (projectedTimeslot.collisions.length > 0) {

Richard Blechinger
committed
newSlot.className = 'conflict'
newSlot.extendedProps.solutionChoices = projectedTimeslot.solutionChoices
for (const collision of projectedTimeslot.collisions) {
const conflictingSlot = {
id: collision.id,
start: collision.start,
end: collision.end,
title: collision.showName,

Richard Blechinger
committed
className: 'otherShow',
editable: false,
extendedProps: {
id: collision.id,
start: collision.start,
end: collision.end,
title: collision.showName,

Richard Blechinger
committed
}
this.calendarSlots.push(conflictingSlot)
this.conflictCount++
newSlot.extendedProps.collisions.push(collision)

Richard Blechinger
committed
}
}
this.calendarSlots.push(newSlot)
}
} catch (err) {
this.$log.error(err)
}
},
resolveConflict(toResolve, mode) {
const calendarSlot = this.calendarSlots.find((s) => s.id === toResolve.id)
const originalSlot = this.resolveData.projected.find((s) => s.hash === toResolve.hash)

Richard Blechinger
committed
// we only need the conflicting slot specifically for theirs-both mode, where there should be only one collision
const conflictingSlot = this.calendarSlots.find((s) => s.id === toResolve.collisions[0].id)

Richard Blechinger
committed
// we only reduce the conflict count, if there was no other solution set already
if (this.conflictSolutions[toResolve.hash] === '') {
this.conflictCount -= toResolve.collisions.length
}
// if there already was a resolution chosen before, that added a second timeslot
// because either the "ours" or "theirs" was split, we have to clean it up before
// in the calendar before setting a new resolution
const oldResolutionSlot = this.calendarSlots.findIndex((s) => s.id === calendarSlot.id * 10)

Richard Blechinger
committed
if (oldResolutionSlot > -1) {
this.calendarSlots.splice(oldResolutionSlot, 1)
}
// and in case their was a ours-* choice before, we have to reset the (one)
// conflicting slot to its original start and end time
conflictingSlot.start = toResolve.collisions[0].start
conflictingSlot.end = toResolve.collisions[0].end
// for a detailed description of the resolution modes, see https://docs.aura.radio/en/latest/user/timeslot-collision-detection.html
// and https://docs.aura.radio/en/latest/developer/misc/conflict-resolution.html

Richard Blechinger
committed
switch (mode) {
case 'theirs':
this.conflictSolutions[toResolve.hash] = mode
calendarSlot.className = 'timeslot-discarded'
calendarSlot.title = this.$t('conflictResolution.conflictingSlot')

Richard Blechinger
committed
calendarSlot.start = originalSlot.start
calendarSlot.end = originalSlot.end
for (const theirs of toResolve.collisions) {
this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-accepted'

Richard Blechinger
committed
}
break
case 'ours':
this.conflictSolutions[toResolve.hash] = mode
calendarSlot.className = 'timeslot-accepted'
calendarSlot.title = this.$t('conflictResolution.conflictingSlot')

Richard Blechinger
committed
calendarSlot.start = originalSlot.start
calendarSlot.end = originalSlot.end
for (const theirs of toResolve.collisions) {
this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-discarded'

Richard Blechinger
committed
}
break
case 'theirs-start':
case 'theirs-end':
case 'theirs-both':
this.conflictSolutions[toResolve.hash] = mode
calendarSlot.className = 'timeslot-partly'
calendarSlot.title = 'new [' + mode + ']'
if (mode === 'theirs-start') {
calendarSlot.start = toResolve.collisions[0].end
calendarSlot.end = originalSlot.end
} else if (mode === 'theirs-end') {
calendarSlot.start = originalSlot.start
calendarSlot.end = toResolve.collisions[0].start
} else {
calendarSlot.start = originalSlot.start
calendarSlot.end = toResolve.collisions[0].start
// TODO: that id generation seems __very__ weird
const id = calendarSlot.id * 10

Richard Blechinger
committed
this.calendarSlots.push({

Richard Blechinger
committed
start: toResolve.collisions[0].end,
end: originalSlot.end,
title: 'new [theirs-both]',
className: 'timeslot-partly',
editable: false,
extendedProps: {
id,
start: toResolve.collisions[0].end,
end: originalSlot.end,
title: 'new [theirs-both]',
},

Richard Blechinger
committed
})
}
for (const theirs of toResolve.collisions) {
this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-accepted'

Richard Blechinger
committed
}
break
case 'ours-start':
case 'ours-end':
case 'ours-both':
this.conflictSolutions[toResolve.hash] = mode
calendarSlot.className = 'timeslot-accepted'
calendarSlot.title = 'new [' + mode + ']'
if (mode === 'ours-start') {
conflictingSlot.start = toResolve.collisions[0].start
conflictingSlot.end = originalSlot.start
} else if (mode === 'ours-end') {
conflictingSlot.start = originalSlot.end
conflictingSlot.end = toResolve.collisions[0].end
} else {
conflictingSlot.start = toResolve.collisions[0].start
conflictingSlot.end = originalSlot.start
// TODO: that id generation seems __very__ weird
const id = calendarSlot.id * 10

Richard Blechinger
committed
this.calendarSlots.push({

Richard Blechinger
committed
start: originalSlot.end,
end: toResolve.collisions[0].end,
title: conflictingSlot.title,
className: 'timeslot-partly',
editable: false,
extendedProps: {
id,
start: originalSlot.end,
end: toResolve.collisions[0].end,
title: conflictingSlot.title,
},

Richard Blechinger
committed
})
}
for (const theirs of toResolve.collisions) {
this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-partly'

Richard Blechinger
committed
}
break
default:
this.$log.error('EmissionManager.resolveEvent')
this.$log.error('toResolve:', toResolve)
this.$log.error('mode:', mode)
alert('Error: an undefined conflict resolution mode was chosen. See console for details')
break
}
},
resolveCancel() {
this.conflictMode = false
},
// submit a conflict-resolved schedule to steering
async resolveSubmit() {
// TODO: check why steering returns undefined and null values here
if (this.resolveData.schedule.addBusinessDaysOnly === undefined) {
this.resolveData.schedule.addBusinessDaysOnly = false

Richard Blechinger
committed
}
if (this.resolveData.schedule.addDaysNo === null) {
this.resolveData.schedule.addDaysNo = 0

Richard Blechinger
committed
}
if (this.resolveData.schedule.isRepetition === undefined) {
this.resolveData.schedule.isRepetition = false

Richard Blechinger
committed
}
if (this.resolveData.schedule.defaultPlaylistId === null) {
this.resolveData.schedule.defaultPlaylistId = 0

Richard Blechinger
committed
}
if (this.resolveData.schedule.automationId === null) {
this.resolveData.schedule.automationId = 0

Richard Blechinger
committed
}
if (this.resolveData.schedule.byWeekday === undefined) {
this.resolveData.schedule.byWeekday = 0

Richard Blechinger
committed
}
// create the resolved schedule object including solutions
const resolvedSchedule = {

Richard Blechinger
committed
schedule: this.resolveData.schedule,
solutions: this.resolveData.solutions,
}

Richard Blechinger
committed
this.$log.debug('resolveSubmit: schedule:', resolvedSchedule)
this.submitting = true
this.serverErrors = []
try {
await this.$store.dispatch('shows/submitSchedule', {
showId: this.selectedShow.id,
schedule: resolvedSchedule,
})
this.conflictMode = false
this.enterConflictMode(e.response.data)
} else {
this.serverErrors = e.errors
}

Richard Blechinger
committed
},
loadCalendarSlots() {
this.calendarSlots = []
for (const timeslot of this.timeslots) {
const isEmpty = !timeslot.playlistId

Richard Blechinger
committed
let highlighting = 'otherShow'
if (timeslot.showId === this.selectedShow.id) {

Richard Blechinger
committed
highlighting = 'currentShow '
highlighting += isEmpty ? 'emptySlot' : ''
emptyText = isEmpty ? this.$t('calendar.empty') : ''
}
const title = sanitizeHTML(this.getShowTitleById(timeslot.showId)) + `\n${emptyText}`

Richard Blechinger
committed
this.calendarSlots.push({
id: timeslot.id,
start: timeslot.start,
end: timeslot.end,
extendedProps: {
id: timeslot.id,
start: timeslot.start,
end: timeslot.end,
durationMinutes:
calculateDurationSeconds(parseISO(timeslot.start), parseISO(timeslot.end)) / 60,

Richard Blechinger
committed
})
}
},
loadTimeslots(start, end) {
this.$store.dispatch('shows/fetchTimeslots', {
start: getISODateString(start ?? this.currentStart),
end: getISODateString(end ?? this.currentEnd),

Richard Blechinger
committed
callback: () => {
this.loadCalendarSlots()

Richard Blechinger
committed
})
},
},
}