Skip to content
Open
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
22 changes: 10 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@nextcloud/timezones": "^1.0.2",
"@nextcloud/vue": "^9.8.2",
"autosize": "^6.0.1",
"chrono-node": "^2.9.1",
"color-convert": "^3.1.3",
"color-string": "^2.1.4",
"core-js": "^3.49.0",
Expand Down
100 changes: 100 additions & 0 deletions src/components/Editor/Properties/TimeSuggestion.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<div class="time-suggestion" role="status" aria-live="polite">
<ClockOutlineIcon :size="16" class="time-suggestion__icon" decorative />
<span class="time-suggestion__text">{{ suggestionLabel }}</span>
<NcButton size="small" variant="tertiary" @click="$emit('apply')">
{{ t('calendar', 'Apply') }}
</NcButton>
<NcButton
size="small"
variant="tertiary"
:aria-label="t('calendar', 'Dismiss time suggestion')"
@click="$emit('dismiss')">
<template #icon>
<CloseIcon :size="16" />
</template>
</NcButton>
</div>
</template>

<script>
import { t } from '@nextcloud/l10n'
import { NcButton } from '@nextcloud/vue'
import ClockOutlineIcon from 'vue-material-design-icons/ClockOutline.vue'
import CloseIcon from 'vue-material-design-icons/Close.vue'

export default {
name: 'TimeSuggestion',

components: {
NcButton,
ClockOutlineIcon,
CloseIcon,
},

props: {
suggestion: {
type: Object,
required: true,
},
},

emits: ['apply', 'dismiss'],

computed: {
suggestionLabel() {
const { type, displayText } = this.suggestion
switch (type) {
case 'all-day':
return t('calendar', 'Mark as all-day event')
case 'time-range':
return t('calendar', 'Set time: {displayText}', { displayText })
case 'time-only':
return t('calendar', 'Set time: {displayText}', { displayText })
case 'date-range':
return t('calendar', 'Set date range: {displayText}', { displayText })
case 'date':
return t('calendar', 'Set date: {displayText}', { displayText })
case 'datetime':
return t('calendar', 'Set date and time: {displayText}', { displayText })
default:
return displayText
}
},
},

methods: { t },
}
</script>

<style lang="scss" scoped>
.time-suggestion {
display: flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 1);
padding: calc(var(--default-grid-baseline) * 1) calc(var(--default-grid-baseline) * 2);
margin-top: calc(var(--default-grid-baseline) * 1);
border-radius: var(--border-radius);
background-color: var(--color-background-dark);
font-size: var(--font-size-small);

&__icon {
flex-shrink: 0;
color: var(--color-text-maxcontrast);
}

&__text {
flex: 1;
color: var(--color-text-maxcontrast);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>
121 changes: 121 additions & 0 deletions src/mixins/TimeSuggestionMixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { getLanguage } from '@nextcloud/l10n'
import { parseNaturalLanguageTime } from '../services/naturalLanguageTimeParserService.js'

export default {
data() {
return {
timeSuggestion: null,
}
},

methods: {
handleTitleUpdate(value) {
this.updateTitle(value)
const parsed = parseNaturalLanguageTime(
value,
this.calendarObjectInstance?.startDate ?? null,
getLanguage(),
)
if (parsed) {
const before = value.slice(0, parsed.matchedIndex)
const after = value.slice(parsed.matchedIndex + parsed.matchedText.length)
parsed.strippedTitle = (before + after).replace(/\s+/g, ' ').trim()
}
this.timeSuggestion = parsed
},

async applyTimeSuggestion() {
const s = this.timeSuggestion
if (!s || !this.calendarObjectInstance) {
return
}

const base = new Date(this.calendarObjectInstance.startDate)

switch (s.type) {
case 'all-day':
if (!this.isAllDay) {
this.toggleAllDay()
}
break
case 'time-range': {
if (this.isAllDay) {
this.toggleAllDay()
}
const start = new Date(base)
start.setHours(s.startHour, s.startMinute, 0, 0)
const end = new Date(base)
end.setHours(s.endHour, s.endMinute, 0, 0)
this.updateStartTime(start)
this.updateEndTime(end)
break
}
case 'time-only': {
if (this.isAllDay) {
this.toggleAllDay()
}
const start = new Date(base)
start.setHours(s.startHour, s.startMinute, 0, 0)
this.updateStartTime(start)
break
}
case 'date-range': {
if (!this.isAllDay) {
this.toggleAllDay()
}
this.updateStartDate(new Date(s.startYear, s.startMonth - 1, s.startDay))
this.updateEndDate(new Date(s.endYear, s.endMonth - 1, s.endDay))
break
}
case 'date': {
if (!this.isAllDay) {
this.toggleAllDay()
}
const d = new Date(s.year, s.month - 1, s.day)
this.updateStartDate(d)
this.updateEndDate(d)
break
}
case 'datetime': {
if (this.isAllDay) {
this.toggleAllDay()
}
const d = new Date(s.year, s.month - 1, s.day, s.startHour, s.startMinute, 0, 0)
this.updateStartDate(d)
this.updateStartTime(d)
break
}
}

// Clear suggestion first so Vue flushes that re-render, then update the
// title in a separate tick so the NcTextField double-useModel chain
// propagates the empty value cleanly to the DOM input.
const strippedTitle = s.strippedTitle ?? ''
this.timeSuggestion = null
await this.$nextTick()
this.updateTitle(strippedTitle)
},

dismissTimeSuggestion() {
this.timeSuggestion = null
},

handleTitleAreaKeydown(event) {
if (!this.timeSuggestion) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
this.applyTimeSuggestion()
} else if (event.key === 'Escape') {
event.stopPropagation()
this.dismissTimeSuggestion()
}
},
},
}
Loading
Loading