diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6355382b..c87b6173 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -26,6 +26,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..e02a9989
--- /dev/null
+++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt
@@ -0,0 +1,285 @@
+package org.stypox.dicio.skills.calendar
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.dicio.skill.context.SkillContext
+import org.dicio.skill.skill.SkillOutput
+import org.stypox.dicio.R
+import org.stypox.dicio.io.graphical.Headline
+import org.stypox.dicio.util.getString
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+sealed interface CalendarOutput : SkillOutput {
+
+ data class Success(
+ private val title: String,
+ private val startDateTime: LocalDateTime,
+ private val durationMillis: Long
+ ) : CalendarOutput {
+ override fun getSpeechOutput(ctx: SkillContext): String {
+ val formattedDateTime = ctx.parserFormatter?.niceDateTime(startDateTime)?.get()
+ ?: startDateTime.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM))
+
+ val durationHours = durationMillis / (60 * 60 * 1000)
+ val durationMinutes = (durationMillis % (60 * 60 * 1000)) / (60 * 1000)
+
+ val durationText = when {
+ durationHours > 0 && durationMinutes > 0 ->
+ ctx.getString(R.string.skill_calendar_duration_hours_minutes, durationHours, durationMinutes)
+ durationHours > 0 ->
+ ctx.getString(R.string.skill_calendar_duration_hours, durationHours)
+ durationMinutes > 0 ->
+ ctx.getString(R.string.skill_calendar_duration_minutes, durationMinutes)
+ else ->
+ ctx.getString(R.string.skill_calendar_duration_hours, 1)
+ }
+
+ return ctx.getString(R.string.skill_calendar_success, title, formattedDateTime, durationText)
+ }
+
+ @Composable
+ override fun GraphicalOutput(ctx: SkillContext) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = ctx.getString(R.string.skill_calendar_event_added),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = startDateTime.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+
+ data object NoTitle : CalendarOutput {
+ override fun getSpeechOutput(ctx: SkillContext): String =
+ ctx.getString(R.string.skill_calendar_no_title)
+
+ @Composable
+ override fun GraphicalOutput(ctx: SkillContext) {
+ Headline(text = getSpeechOutput(ctx))
+ }
+ }
+
+ data class EventsList(
+ private val events: List,
+ private val queryDate: LocalDateTime
+ ) : CalendarOutput {
+ override fun getSpeechOutput(ctx: SkillContext): String {
+ if (events.isEmpty()) {
+ return NoEvents(queryDate).getSpeechOutput(ctx)
+ }
+
+ val formattedDate = ctx.parserFormatter?.niceDate(queryDate.toLocalDate())?.get()
+ ?: queryDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
+
+ val maxEventsToRead = 5
+ val eventsToRead = if (events.size > maxEventsToRead) events.take(maxEventsToRead) else events
+
+ val eventList = eventsToRead.joinToString(", ") { event ->
+ if (event.isAllDay) {
+ "${event.title} (${ctx.getString(R.string.skill_calendar_all_day)})"
+ } else {
+ val time = ctx.parserFormatter?.niceTime(event.startDateTime.toLocalTime())?.get()
+ ?: event.startDateTime.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
+ "${event.title} ${ctx.getString(R.string.skill_calendar_event_at)} $time"
+ }
+ }
+
+ val prefix = if (events.size > maxEventsToRead) {
+ ctx.getString(R.string.skill_calendar_on_date_you_have_count, formattedDate, events.size) + ". " +
+ ctx.getString(R.string.skill_calendar_here_are_first, maxEventsToRead) + ": "
+ } else if (events.size == 1) {
+ ctx.getString(R.string.skill_calendar_on_date_you_have_one_event, formattedDate) + ": "
+ } else {
+ ctx.getString(R.string.skill_calendar_on_date_you_have_count, formattedDate, events.size) + ": "
+ }
+
+ return prefix + eventList
+ }
+
+ @Composable
+ override fun GraphicalOutput(ctx: SkillContext) {
+ if (events.isEmpty()) {
+ NoEvents(queryDate).GraphicalOutput(ctx)
+ return
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ val dateStr = queryDate.format(
+ DateTimeFormatter.ofPattern("MMMM d, yyyy", java.util.Locale.getDefault())
+ )
+
+ Text(
+ text = dateStr,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = if (events.size == 1)
+ ctx.getString(R.string.skill_calendar_one_event_found)
+ else
+ ctx.getString(R.string.skill_calendar_events_found, events.size),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Show event summaries without dates
+ events.forEach { event ->
+ val displayText = if (event.isAllDay) {
+ "${event.title} (${ctx.getString(R.string.skill_calendar_all_day_capitalized)})"
+ } else {
+ val timeStr = event.startDateTime.format(
+ DateTimeFormatter.ofPattern("h:mma", java.util.Locale.getDefault())
+ ).lowercase()
+ "${event.title} @ $timeStr"
+ }
+
+ Text(
+ text = displayText,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(events) { event ->
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp)
+ ) {
+ Text(
+ text = event.title,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = if (event.isAllDay)
+ ctx.getString(R.string.skill_calendar_all_day_capitalized)
+ else event.startDateTime.format(
+ DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ if (event.location != null) {
+ Text(
+ text = event.location,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ data class NoEvents(
+ private val queryDate: LocalDateTime
+ ) : CalendarOutput {
+ override fun getSpeechOutput(ctx: SkillContext): String {
+ val formattedDate = ctx.parserFormatter?.niceDate(queryDate.toLocalDate())?.get()
+ ?: queryDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
+ return ctx.getString(R.string.skill_calendar_no_events, formattedDate)
+ }
+
+ @Composable
+ override fun GraphicalOutput(ctx: SkillContext) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ val dateStr = queryDate.format(
+ DateTimeFormatter.ofPattern("MMMM d, yyyy", java.util.Locale.getDefault())
+ )
+
+ Text(
+ text = dateStr,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = ctx.getString(R.string.skill_calendar_no_events_simple),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ data object NoCalendarApp : CalendarOutput {
+ override fun getSpeechOutput(ctx: SkillContext): String =
+ ctx.getString(R.string.skill_calendar_no_app)
+
+ @Composable
+ override fun GraphicalOutput(ctx: SkillContext) {
+ Headline(text = getSpeechOutput(ctx))
+ }
+ }
+}
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..9bb00d34
--- /dev/null
+++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt
@@ -0,0 +1,205 @@
+package org.stypox.dicio.skills.calendar
+
+import android.content.Intent
+import android.provider.CalendarContract
+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.Instant
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneId
+
+class CalendarSkill(
+ correspondingSkillInfo: SkillInfo,
+ data: StandardRecognizerData
+) : StandardRecognizerSkill(correspondingSkillInfo, data) {
+
+ override suspend fun generateOutput(ctx: SkillContext, inputData: Calendar): SkillOutput {
+ return when (inputData) {
+ is Calendar.CreateEvent -> createEvent(ctx, inputData)
+ is Calendar.QueryEvents -> queryEvents(ctx, inputData)
+ }
+ }
+
+ private fun createEvent(ctx: SkillContext, inputData: Calendar.CreateEvent): SkillOutput {
+ val title = inputData.title
+ val dateTimeStr = inputData.dateTime
+ val durationInput = inputData.duration
+
+ // Validate title
+ if (title.isNullOrBlank()) {
+ return CalendarOutput.NoTitle
+ }
+
+ val npf = ctx.parserFormatter
+ var cleanTitle = title.trim()
+ .split(" ")
+ .joinToString(" ") { word -> word.replaceFirstChar { it.uppercase() } }
+ var extractedDateTime: LocalDateTime? = null
+
+ // First, try to parse date/time from the explicit dateTimeStr capture
+ if (!dateTimeStr.isNullOrBlank() && npf != null) {
+ extractedDateTime = npf.extractDateTime(dateTimeStr)
+ .now(LocalDateTime.now())
+ .preferMonthBeforeDay(false)
+ .first
+ }
+
+ // If no date/time found in explicit capture, check if the title contains date/time info
+ if (extractedDateTime == null && npf != null) {
+ val mixedResult = npf.extractDateTime(cleanTitle)
+ .now(LocalDateTime.now())
+ .preferMonthBeforeDay(false)
+ .mixedWithText
+
+ // Check if we found a date/time and extract the first one
+ var foundDateTime = false
+ val titleParts = mutableListOf()
+
+ for (item in mixedResult) {
+ when (item) {
+ is LocalDateTime -> {
+ if (!foundDateTime) {
+ extractedDateTime = item
+ foundDateTime = true
+ }
+ // Don't add LocalDateTime to titleParts - we're removing it
+ }
+ is String -> titleParts.add(item)
+ }
+ }
+
+ // Only update the title if we actually found a date/time to remove
+ if (foundDateTime) {
+ val reconstructedTitle = titleParts.joinToString("").trim()
+ if (reconstructedTitle.isNotBlank()) {
+ cleanTitle = reconstructedTitle
+ }
+ }
+ }
+
+ // Default to current time if still no date/time found
+ val startDateTime = extractedDateTime ?: LocalDateTime.now()
+
+ // Parse duration or default to 1 hour
+ val durationMillis: Long = if (durationInput != null && npf != null) {
+ val parsedDuration = npf.extractDuration(durationInput)
+ .first?.toJavaDuration()
+ parsedDuration?.toMillis() ?: (60 * 60 * 1000L) // default 1 hour
+ } else {
+ 60 * 60 * 1000L // default 1 hour (in milliseconds)
+ }
+
+ // Calculate start and end times in milliseconds
+ val startMillis = startDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ val endMillis = startMillis + durationMillis
+
+ // Create calendar intent
+ val calendarIntent = Intent(Intent.ACTION_INSERT).apply {
+ data = CalendarContract.Events.CONTENT_URI
+ putExtra(CalendarContract.Events.TITLE, cleanTitle)
+ putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startMillis)
+ putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMillis)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
+ // Check if there's an app that can handle the calendar intent
+ val packageManager = ctx.android.packageManager
+ val canHandleIntent = calendarIntent.resolveActivity(packageManager) != null
+
+ return if (canHandleIntent) {
+ ctx.android.startActivity(calendarIntent)
+ CalendarOutput.Success(cleanTitle, startDateTime, durationMillis)
+ } else {
+ CalendarOutput.NoCalendarApp
+ }
+ }
+
+ private fun queryEvents(ctx: SkillContext, inputData: Calendar.QueryEvents): SkillOutput {
+ val dateTimeStr = inputData.dateTime
+ val npf = ctx.parserFormatter
+
+ // Parse the date/time or default to today
+ val parsedDateTime: LocalDateTime = if (!dateTimeStr.isNullOrBlank() && npf != null) {
+ npf.extractDateTime(dateTimeStr)
+ .now(LocalDateTime.now())
+ .preferMonthBeforeDay(false)
+ .first ?: LocalDateTime.now()
+ } else {
+ LocalDateTime.now()
+ }
+
+ // Set to start of day for the query (we only care about the date, not the time)
+ val startOfDay = parsedDateTime.toLocalDate().atStartOfDay()
+ val endOfDay = parsedDateTime.toLocalDate().atTime(LocalTime.MAX)
+
+ // Convert to milliseconds for calendar query
+ val startMillis = startOfDay.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ val endMillis = endOfDay.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+
+ // Query calendar events using Instances table (handles recurring events properly)
+ val events = mutableListOf()
+ val contentResolver = ctx.android.contentResolver
+
+ // Build the URI for querying instances in the time range
+ val instancesUri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
+ appendPath(startMillis.toString())
+ appendPath(endMillis.toString())
+ }.build()
+
+ val projection = arrayOf(
+ CalendarContract.Instances.EVENT_ID,
+ CalendarContract.Instances.TITLE,
+ CalendarContract.Instances.BEGIN,
+ CalendarContract.Instances.END,
+ CalendarContract.Instances.EVENT_LOCATION,
+ CalendarContract.Instances.ALL_DAY
+ )
+
+ val sortOrder = "${CalendarContract.Instances.BEGIN} ASC"
+
+ try {
+ contentResolver.query(
+ instancesUri,
+ projection,
+ null, // selection handled by URI
+ null, // selectionArgs handled by URI
+ sortOrder
+ )?.use { cursor ->
+ val titleIndex = cursor.getColumnIndex(CalendarContract.Instances.TITLE)
+ val startIndex = cursor.getColumnIndex(CalendarContract.Instances.BEGIN)
+ val endIndex = cursor.getColumnIndex(CalendarContract.Instances.END)
+ val locationIndex = cursor.getColumnIndex(CalendarContract.Instances.EVENT_LOCATION)
+ val allDayIndex = cursor.getColumnIndex(CalendarContract.Instances.ALL_DAY)
+
+ while (cursor.moveToNext()) {
+ val title = if (titleIndex != -1) cursor.getString(titleIndex) ?: "Untitled" else "Untitled"
+ val startTimeMillis = if (startIndex != -1) cursor.getLong(startIndex) else continue
+ val endTimeMillis = if (endIndex != -1) cursor.getLong(endIndex) else startTimeMillis + (60 * 60 * 1000)
+ val location = if (locationIndex != -1) cursor.getString(locationIndex) else null
+ val isAllDay = if (allDayIndex != -1) cursor.getInt(allDayIndex) == 1 else false
+
+ val startDateTime = LocalDateTime.ofInstant(
+ Instant.ofEpochMilli(startTimeMillis),
+ ZoneId.systemDefault()
+ )
+ val endDateTime = LocalDateTime.ofInstant(
+ Instant.ofEpochMilli(endTimeMillis),
+ ZoneId.systemDefault()
+ )
+
+ events.add(CalendarEvent(title, startDateTime, endDateTime, location, isAllDay))
+ }
+ }
+ } catch (e: Exception) {
+ // Handle permission or other errors gracefully
+ return CalendarOutput.NoEvents(startOfDay)
+ }
+
+ return CalendarOutput.EventsList(events, startOfDay)
+ }
+}
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 f06456cd..ff5bdab1 100644
--- a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt
+++ b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt
@@ -27,6 +27,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,
+)
/**
* @param context the Android context
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8230af59..e55ea35a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -118,6 +118,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
@@ -146,6 +147,28 @@
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 to your calendar on %2$s for %3$s
+ Event added to calendar
+ Please specify an event title
+ No calendar app found. Please install a calendar app to add events
+ %1$d hour
+ %1$d minutes
+ %1$d hour and %2$d minutes
+ %1$d events found
+ 1 event found
+ You have %2$d events on %1$s: %3$s
+ No events found on %1$s
+ No events found
+ all day
+ All day
+ at
+ On %1$s, you have %2$d events
+ On %1$s, you have 1 event
+ Here are the first %1$d
+ event
+ events
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..9e8cb9bf
--- /dev/null
+++ b/app/src/main/sentences/en/calendar.yml
@@ -0,0 +1,15 @@
+create_event:
+ - (add|create|schedule|make|put) .title. (to|on|in my?)? calendar? ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)?
+ - (add|create|schedule|make) (a|an)? (calendar|new)? event (called|named|for)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)?
+ - (set up|book) (a|an)? (meeting|appointment|event)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)?
+ - remind me (about|of|to)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)?
+ - (on|for) .date_time. (add|create|schedule|make) .title. (to|on)? calendar?
+ - (block|hold|reserve) .duration. (for)? .title. ((on|at|starting)? .date_time.)?
+
+query_events:
+ - what (events|appointments|meetings|plans) (do i have|are (on|in) my? calendar|are scheduled) (today|tomorrow|.date_time.)?
+ - (what s|whats) on my? calendar (today|tomorrow|(for|on)? .date_time.)?
+ - (do i have|show me|list) (any|my)? (events|appointments|meetings|plans) (today|tomorrow|(for|on)? .date_time.)?
+ - (show|tell me|read) (me)? my? (calendar|schedule|agenda) ((for|on)? (today|tomorrow|.date_time.))?
+ - (am i|are we) (busy|free) (today|tomorrow|on .date_time.)?
+ - (what|anything) (is|s) (happening|scheduled|planned) (today|tomorrow|on .date_time.)?
\ No newline at end of file
diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml
index 7aa23f72..70dc63ea 100644
--- a/app/src/main/sentences/skill_definitions.yml
+++ b/app/src/main/sentences/skill_definitions.yml
@@ -123,3 +123,19 @@ skills:
type: string
- id: target
type: string
+
+ - id: calendar
+ specificity: high
+ sentences:
+ - id: create_event
+ captures:
+ - id: title
+ type: string
+ - id: date_time
+ type: string
+ - id: duration
+ type: duration
+ - id: query_events
+ captures:
+ - id: date_time
+ type: string