diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c66e4da0..ad9d4e7f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,9 @@ + + + = listOf(PERMISSION_READ_CALENDAR) + + override fun build(ctx: SkillContext): Skill<*> { + return CalendarSkill(CalendarInfo, Sentences.Calendar[ctx.sentencesLanguage]!!) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt new file mode 100644 index 00000000..99e03a62 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -0,0 +1,253 @@ +package org.stypox.dicio.skills.calendar + +import android.content.ContentUris +import android.content.Intent +import android.provider.CalendarContract +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.di.SkillContextImpl +import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput +import org.stypox.dicio.util.StringUtils +import org.stypox.dicio.util.getPluralString +import org.stypox.dicio.util.getString + +// TODO remind me about whatever tomorrow at nine is misinterpreted +sealed interface CalendarOutput : SkillOutput { + + data class Added( + private val title: String, + private val begin: LocalDateTime, + private val end: LocalDateTime + ) : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + val duration = Duration.between(begin, end) + val beginText = ctx.parserFormatter!! + .niceDateTime(begin) + .get() + + return if (duration < Duration.ofHours(20)) { + val durationText = ctx.parserFormatter!! + .niceDuration(DicioNumbersDuration(duration)) + .speech(true) + .get() + ctx.getString(R.string.skill_calendar_adding_begin_duration, title, beginText, durationText) + } else { + val endText = ctx.parserFormatter!! + .niceDateTime(end) + .get() + ctx.getString(R.string.skill_calendar_adding_begin_end, title, beginText, endText) + } + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + val dateRangeFormatted = remember { formatDateTimeRange(ctx, begin, end) } + val duration = remember { Duration.between(begin, end) } + val durationFormatted = remember(duration) { formatDuration(ctx, duration) } + + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = ctx.getString(R.string.skill_calendar_app_was_instructed), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(3.dp)) + + Text( + text = dateRangeFormatted, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + if (duration >= Duration.ofSeconds(1)) { + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = stringResource(R.string.skill_calendar_duration, durationFormatted), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + } + } + } + + data object NoCalendarApp : CalendarOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = + ctx.getString(R.string.skill_calendar_no_app) + } + + data class EventsList( + private val events: List, + private val queryDate: LocalDate + ) : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + val formattedEvents = events + .take(MAX_EVENTS_TO_SPEAK) + .joinToString(", ") { event -> event.toSpeechString(ctx, queryDate) } + + return if (events.size <= MAX_EVENTS_TO_SPEAK) { + ctx.getPluralString( + resId = R.plurals.skill_calendar_on_date_you_have_count, + resIdIfZero = R.string.skill_calendar_on_date_you_have_count_zero, + quantity = events.size, + formattedEvents, + ) + } else { + ctx.getPluralString( + resId = R.plurals.skill_calendar_on_date_you_have_count_limited, + resIdIfZero = R.string.skill_calendar_on_date_you_have_count_zero, + quantity = events.size, + MAX_EVENTS_TO_SPEAK, + formattedEvents + ) + } + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = queryDate.format( + DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.getDefault()) + ), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = ctx.getPluralString( + resId = R.plurals.skill_calendar_events, + quantity = events.size, + resIdIfZero = R.string.skill_calendar_events_zero + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + for (event in events) { + EventCard( + ctx = ctx, + event = event, + queryDate = queryDate, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + } + } + + companion object { + const val MAX_EVENTS_TO_SPEAK = 5 + } + } +} + +@Composable +private fun EventCard( + ctx: SkillContext, + event: CalendarEvent, + queryDate: LocalDate, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + onClick = { + if (event.id == null) { + return@Card + } + // open the full event description in the calendar app + val intent = Intent(Intent.ACTION_VIEW).apply { + data = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, event.id!!) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ctx.android.startActivity(intent) + }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Text( + text = StringUtils.joinNonBlank(event.title, event.location) + .takeIf(String::isNotEmpty) + ?: ctx.getString(R.string.skill_calendar_no_name), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 11.dp, top = 8.dp, end = 11.dp, bottom = 2.dp) + ) + Text( + text = when { + event.isAllDay(queryDate) -> ctx.getString(R.string.skill_calendar_all_day) + event.begin != null && event.end != null -> formatDateTimeRange(ctx, event.begin, event.end) + event.end != null -> formatDateTime(event.end) + event.begin != null -> formatDateTime(event.begin) + else -> ctx.getString(R.string.skill_calendar_unknown_time) + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 11.dp, end = 11.dp, bottom = 8.dp) + ) + } +} + +@Preview +@Composable +private fun EventCardPreview() { + EventCard( + ctx = SkillContextImpl.newForPreviews(LocalContext.current), + event = CalendarEvent( + id = null, + title = "Meet with John", + begin = LocalDateTime.of(2026, 2, 26, 18, 0), + end = LocalDateTime.of(2026, 2, 26, 21, 0), + location = "Online", + isAllDay = false, + ), + queryDate = LocalDate.of(2026, 2, 26), + ) +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt new file mode 100644 index 00000000..6c529e2a --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -0,0 +1,115 @@ +package org.stypox.dicio.skills.calendar + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.provider.CalendarContract +import android.provider.CalendarContract.Instances as CCI +import android.util.Log +import java.time.Duration +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.standard.StandardRecognizerData +import org.dicio.skill.standard.StandardRecognizerSkill +import org.stypox.dicio.sentences.Sentences.Calendar +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import org.stypox.dicio.R +import org.stypox.dicio.util.getString + + +class CalendarSkill( + correspondingSkillInfo: SkillInfo, + data: StandardRecognizerData +) : StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: Calendar): SkillOutput { + return when (inputData) { + is Calendar.Create -> createEvent(ctx, inputData) + is Calendar.Query -> queryEvents(ctx, inputData) + } + } + + private fun createEvent(ctx: SkillContext, inputData: Calendar.Create): SkillOutput { + // obtain capturing group data (note that we either have .end. or .duration., never both) + val title = inputData.title + ?.trim() + ?.replaceFirstChar { it.titlecase(ctx.locale) } + ?: ctx.getString(R.string.skill_calendar_no_name) + var begin = inputData.begin + ?: LocalDateTime.now() + var end = inputData.end + ?: begin.plus(inputData.duration?.toJavaDuration() ?: Duration.ZERO) + if (begin.isAfter(end)) { + val tmpBegin = begin + begin = end + end = tmpBegin + } + + // create calendar intent + val calendarIntent = Intent(Intent.ACTION_INSERT).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + data = CalendarContract.Events.CONTENT_URI + putExtra(CalendarContract.Events.TITLE, title) + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, begin.toEpochMilli()) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, end.toEpochMilli()) + } + + // start activity + return try { + ctx.android.startActivity(calendarIntent) + CalendarOutput.Added(title, begin, end) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "Could not start calendar activity", e) + CalendarOutput.NoCalendarApp + } + } + + private fun queryEvents(ctx: SkillContext, inputData: Calendar.Query): SkillOutput { + // we only care about the date, not the time + val date = inputData.`when`?.toLocalDate() ?: LocalDate.now() + val events = ArrayList() + + ctx.android.contentResolver.query( + // query all events from the beginning to the end of the day + CCI.CONTENT_URI.buildUpon() + .appendPath(date.atStartOfDay().toEpochMilli().toString()) + .appendPath(date.atTime(LocalTime.MAX).toEpochMilli().toString()) + .build(), + // we want to read these fields + arrayOf(CCI.EVENT_ID, CCI.TITLE, CCI.BEGIN, CCI.END, CCI.EVENT_LOCATION, CCI.ALL_DAY), + null, // selection handled by URI + null, // selectionArgs handled by URI + "${CCI.BEGIN} ASC" // order them by begin + )?.use { cursor -> + // use ...OrThrow() because all fields surely exist as we requested them in query() + val eventIdIndex = cursor.getColumnIndexOrThrow(CCI.EVENT_ID) + val titleIndex = cursor.getColumnIndexOrThrow(CCI.TITLE) + val beginIndex = cursor.getColumnIndexOrThrow(CCI.BEGIN) + val endIndex = cursor.getColumnIndexOrThrow(CCI.END) + val locationIndex = cursor.getColumnIndexOrThrow(CCI.EVENT_LOCATION) + val allDayIndex = cursor.getColumnIndexOrThrow(CCI.ALL_DAY) + + // move through all rows returned by the query and read the fields + while (cursor.moveToNext()) { + events.add( + CalendarEvent( + id = cursor.getLongOrNull(eventIdIndex), + title = cursor.getNonBlankStringOrNull(titleIndex), + begin = cursor.getDateTimeOrNull(beginIndex), + end = cursor.getDateTimeOrNull(endIndex), + location = cursor.getNonBlankStringOrNull(locationIndex), + isAllDay = cursor.getBooleanOrFalse(allDayIndex), + ) + ) + } + } + + return CalendarOutput.EventsList(events, date) + } + + companion object { + private const val TAG: String = "CalendarSkill" + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt new file mode 100644 index 00000000..763cd728 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt @@ -0,0 +1,59 @@ +package org.stypox.dicio.skills.calendar + +import android.database.Cursor +import android.text.format.DateUtils +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import org.dicio.skill.context.SkillContext + +typealias DicioNumbersDuration = org.dicio.numbers.unit.Duration + +internal fun LocalDateTime.toEpochMilli(): Long { + return atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() +} + +internal fun formatDateTimeRange(ctx: SkillContext, begin: LocalDateTime, end: LocalDateTime): String { + return DateUtils.formatDateRange( + ctx.android, + begin.toEpochMilli(), + end.toEpochMilli(), + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_ALL + ) +} + +internal fun formatDuration(ctx: SkillContext, duration: Duration): String { + return ctx.parserFormatter + ?.niceDuration(DicioNumbersDuration(duration)) + ?.speech(false) + ?.get() + ?: DateUtils.formatElapsedTime(duration.toSeconds()) +} + +internal fun formatDateTime(date: LocalDateTime): String { + return date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)) +} + +// some simple helper functions to work with Cursor... +internal fun Cursor.getNonBlankStringOrNull(index: Int): String? { + return index.takeUnless(this::isNull)?.let(this::getString)?.takeUnless(String::isBlank) +} + +internal fun Cursor.getLongOrNull(index: Int): Long? { + return index.takeUnless(this::isNull)?.let(this::getLong) +} + +internal fun Cursor.getDateTimeOrNull(index: Int): LocalDateTime? { + val millis = getLongOrNull(index) ?: return null + return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneOffset.UTC) +} + +internal fun Cursor.getBooleanOrFalse(index: Int): Boolean { + return index.takeUnless(this::isNull)?.let(this::getInt) == 1 +} diff --git a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt index 79617da9..32cd335c 100644 --- a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt +++ b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt @@ -26,6 +26,10 @@ val PERMISSION_CALL_PHONE = Permission.NormalPermission( name = R.string.perm_call_phone, id = Manifest.permission.CALL_PHONE, ) +val PERMISSION_READ_CALENDAR = Permission.NormalPermission( + name = R.string.perm_read_calendar, + id = Manifest.permission.READ_CALENDAR, +) val PERMISSION_NOTIFICATION_LISTENER = Permission.SecurePermission( name = R.string.perm_notification_listener, diff --git a/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt b/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt index 99efd106..849521b3 100644 --- a/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt +++ b/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt @@ -1,5 +1,6 @@ package org.stypox.dicio.util +import androidx.annotation.PluralsRes import androidx.annotation.StringRes import org.dicio.skill.context.SkillContext @@ -10,3 +11,15 @@ fun SkillContext.getString(@StringRes resId: Int): String { fun SkillContext.getString(@StringRes resId: Int, vararg formatArgs: Any?): String { return this.android.getString(resId, *formatArgs) } + +fun SkillContext.getPluralString( + @PluralsRes resId: Int, + @StringRes resIdIfZero: Int, + quantity: Int, + vararg otherFormatArgs: Any?, +): String { + if (quantity == 0 && resIdIfZero != 0) { + return this.android.getString(resIdIfZero, quantity, *otherFormatArgs) + } + return this.android.resources.getQuantityString(resId, quantity, quantity, *otherFormatArgs) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea8db666..b002b152 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,6 +120,7 @@ Do not play sound read your contacts directly call phone numbers + read your calendar %1$s — %2$s The skill \"%1$s\" needs these permissions to work: %2$s Could not evaluate your request @@ -148,6 +149,42 @@ Navigate to Vancouver international airport Specify where you want to navigate to Navigating to %1$s + Calendar + Add dentist appointment tomorrow at 3pm + Adding %1$s on %2$s lasting %3$s + Adding %1$s from %2$s to %3$s + Your calendar app was instructed to add this event: + Duration: %1$s + No calendar app found, please install a calendar app to add events + %1$s for all day + %1$s in %2$s for all day + in %1$s for all day + unnamed event for all day + %1$s at %2$s + %1$s in %2$s at %3$s + in %1$s at %2$s + unnamed event at %1$s + %1$s + %1$s in %2$s + in %1$s + unnamed event + You have nothing scheduled + + You have %1$d event: %2$s + You have %1$d events: %2$s + + + You have %1$d event; the first %2$d are: %3$s + You have %1$d events; the first %2$d are: %3$s + + No events found + + %1$d event + %1$d events + + All day + No name + Unknown time Telephone Call Tom Timer diff --git a/app/src/main/sentences/en/calendar.yml b/app/src/main/sentences/en/calendar.yml new file mode 100644 index 00000000..733597d9 --- /dev/null +++ b/app/src/main/sentences/en/calendar.yml @@ -0,0 +1,8 @@ +create: + - (add|create|schedule|make|put|(arrange for?)|book|setup|(set up) ((a|an|the event|appointment|meeting called|named|(with the name)|for?)? .title. to|on|in my|the? calendar|agenda|schedule|timetable)|(a|an|the event|appointment|meeting called|named|(with the name)|for? .title.))|(schedule|(reserve a time? slot for)|(remind me about|of|to?) .title.) (starting|beginning|(that begin) on|at|for|from? the?)|(on|at|for|from the?)? .begin. and? (for|lasting|(with duration)|(that last)? .duration.)|(that is .duration. long)|(finishing|(that finish)|ending|(that end)|(lasting|(that lasts)? until)|to .end.)? + - block|hold|reserve|book .duration. about|of|to|for? .title. (starting|beginning|(that begin) on|at|for|from? the?)|(on|at|for|from the?) .begin. + +query: + - (what event|appointment|meeting|plan? (do|will|did i have)|(are|is|was|(will be) there?))|((do|will|did i have)|(are|is|was|(will be) there?)|(show|tell|list|read me?)? any|my|the|an? event|appointment|meeting|plan (i have)?) (on|in|inside my|the? calendar|agenda|schedule|timetable)|scheduled|arranged|happening|planned? (on|at|for? the? .when.)? (on|in|inside my|the? calendar|agenda|schedule|timetable)? + - (show|tell|list|read me?)|((do|will|did i have)|(is|was there? be?) anything on|in|inside) the|my? calendar|agenda|schedule|timetable (on|at|for? the? .when.)? + - ((am i)|(was i)|(will i be) busy|free|occupied)|(do|will|did i have free? time) (on|at|for? the? .when.)? diff --git a/app/src/main/sentences/it/calendar.yml b/app/src/main/sentences/it/calendar.yml new file mode 100644 index 00000000..54ff292a --- /dev/null +++ b/app/src/main/sentences/it/calendar.yml @@ -0,0 +1,8 @@ +create: + - (aggiungi|crea|pianifica|imposta|metti|fissa|programma|calendarizza ((un|uno|una|il|lo|la|l|i|gli|le event|appuntamento|incontro|meeting chiamat|denominat|(con il nome)|(di nome)|di|del|(per|con un|uno|una|il|lo|la|l|i|gli|le?)?)? .title. a|su|ne mi? calendari|agend|diari)|(un|uno|una|il|lo|la|l|i|gli|le evento|appuntamento|incontro|meeting chiamat|denominat|(con il nome)|(di nome)|di|del|(per|con un|uno|una|il|lo|la|l|i|gli|le?)? .title.))|((riserva|blocca|tieni un|uno|una buco|spazio|(fascia oraria) per)|(ricorda di?) .title.) (che? comincia|inizia|part a?)|(a partire da)|a? .begin. e? ((di durata?)|(che dura)|(con una durata di)|lung .duration.)|((((che dura|arriva)? fino)|(che? finisc)|(con fine|finale) a?)|a .end.) + - riserva|blocca|tieni (un|uno|una buco|spazio|(fascia oraria) di)? .duration. di|del|(per|con il|lo|la|l|i|gli|le?) .title. (che? comincia|inizia|part a?)|(a partire da)|a? .begin. + +query: + - (che|qual event|appuntament|incontr|meeting|pian|programm (ho avuto?)|avro|avevo|(c (sono|e stat)|sara|era))|(mostra|di|leggi|(ho avuto?)|avro|avevo|(c (sono|e stat)|sara|era) un|uno|una|il|lo|la|l|i|gli|le|de event|appuntament|incontr|meeting|pian|programm) programmat|(in programma)? (su|ne mi? calendari|agend|diari)? ((per (il giorno?)?)|il|a .when.)? (su|ne mi? calendari|agend|diari)? + - mostra|di|leggi|((ho avuto?)|avro|avevo|(c (sono|e stat)|sara|era) qualcosa) su|ne mi? calendari|agend|diari ((per (il giorno?)?)|il|a .when.)? + - (sono|saro|ero occupato|preso|libero)|(ho|avevo|avro tempo libero|(a disposizione)?)((per? (il giorno?)?)|il|a .when.)? diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index ed347e96..7d1b12e8 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -134,3 +134,21 @@ skills: type: string - id: target type: string + + - id: calendar + specificity: high + sentences: + - id: create + captures: + - id: title + type: string + - id: begin + type: date_time + - id: end + type: date_time + - id: duration + type: duration + - id: query + captures: + - id: when + type: date_time