Microsoft recurrence sync

Limit recurrence options for Microsoft tasks
pull/2352/head
Alex Baker 2 years ago
parent a2ef184c7d
commit f62de8b7f3

@ -155,7 +155,7 @@ data class Task(
} }
val isRecurring: Boolean val isRecurring: Boolean
get() = !Strings.isNullOrEmpty(recurrence) get() = recurrence?.isNotBlank() == true
fun setRecurrence(rrule: Recur?) { fun setRecurrence(rrule: Recur?) {
recurrence = rrule?.toString() recurrence = rrule?.toString()

@ -13,12 +13,17 @@ import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.composethemeadapter.MdcTheme import com.google.android.material.composethemeadapter.MdcTheme
import com.todoroo.astrid.api.CaldavFilter
import com.todoroo.astrid.api.GtasksFilter
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import net.fortuna.ical4j.model.Recur import net.fortuna.ical4j.model.Recur
import net.fortuna.ical4j.model.WeekDay import net.fortuna.ical4j.model.WeekDay
import org.tasks.R import org.tasks.R
import org.tasks.compose.collectAsStateLifecycleAware import org.tasks.compose.collectAsStateLifecycleAware
import org.tasks.compose.edit.RepeatRow import org.tasks.compose.edit.RepeatRow
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavDao
import org.tasks.repeats.BasicRecurrenceDialog import org.tasks.repeats.BasicRecurrenceDialog
import org.tasks.repeats.RecurrenceUtils.newRecur import org.tasks.repeats.RecurrenceUtils.newRecur
import org.tasks.repeats.RepeatRuleToString import org.tasks.repeats.RepeatRuleToString
@ -30,6 +35,7 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RepeatControlSet : TaskEditControlFragment() { class RepeatControlSet : TaskEditControlFragment() {
@Inject lateinit var repeatRuleToString: RepeatRuleToString @Inject lateinit var repeatRuleToString: RepeatRuleToString
@Inject lateinit var caldavDao: CaldavDao
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_RECURRENCE) { if (requestCode == REQUEST_RECURRENCE) {
@ -86,13 +92,27 @@ class RepeatControlSet : TaskEditControlFragment() {
}, },
repeatAfterCompletion = viewModel.repeatAfterCompletion.collectAsStateLifecycleAware().value, repeatAfterCompletion = viewModel.repeatAfterCompletion.collectAsStateLifecycleAware().value,
onClick = { onClick = {
lifecycleScope.launch {
val accountType = viewModel.selectedList.value
.let {
when (it) {
is CaldavFilter -> it.account
is GtasksFilter -> it.account
else -> null
}
}
?.let { caldavDao.getAccountByUuid(it) }
?.accountType
?: CaldavAccount.TYPE_LOCAL
BasicRecurrenceDialog.newBasicRecurrenceDialog( BasicRecurrenceDialog.newBasicRecurrenceDialog(
this@RepeatControlSet, target = this@RepeatControlSet,
REQUEST_RECURRENCE, rc = REQUEST_RECURRENCE,
viewModel.recurrence.value, rrule = viewModel.recurrence.value,
viewModel.dueDate.value.let { if (it > 0) it else currentTimeMillis() } dueDate = viewModel.dueDate.value,
accountType = accountType,
) )
.show(parentFragmentManager, FRAG_TAG_BASIC_RECURRENCE) .show(parentFragmentManager, FRAG_TAG_BASIC_RECURRENCE)
}
}, },
onRepeatFromChanged = { viewModel.repeatAfterCompletion.value = it } onRepeatFromChanged = { viewModel.repeatAfterCompletion.value = it }
) )

@ -156,7 +156,7 @@ fun CustomRecurrence(
selected = state.selectedDays, selected = state.selectedDays,
toggle = toggleDay, toggle = toggleDay,
) )
} else if (state.frequency == Recur.Frequency.MONTHLY) { } else if (state.frequency == Recur.Frequency.MONTHLY && !state.isMicrosoftTask) {
MonthlyPicker( MonthlyPicker(
monthDay = state.monthDay, monthDay = state.monthDay,
dayNumber = state.dueDayOfMonth, dayNumber = state.dueDayOfMonth,
@ -167,6 +167,7 @@ fun CustomRecurrence(
onSelected = setMonthSelection, onSelected = setMonthSelection,
) )
} }
if (!state.isMicrosoftTask) {
Divider( Divider(
modifier = Modifier.padding(vertical = if (state.frequency == Recur.Frequency.WEEKLY) 11.dp else 16.dp), modifier = Modifier.padding(vertical = if (state.frequency == Recur.Frequency.WEEKLY) 11.dp else 16.dp),
color = border() color = border()
@ -182,6 +183,7 @@ fun CustomRecurrence(
} }
} }
} }
}
} }
@Composable @Composable

@ -185,7 +185,7 @@ class CaldavAccount : Parcelable {
} }
override fun toString(): String { override fun toString(): String {
return "CaldavAccount(id=$id, uuid=$uuid, name=$name, url=$url, username=$username, password=$password, error=$error, accountType=$accountType, isCollapsed=$isCollapsed, serverType=$serverType)" return "CaldavAccount(id=$id, uuid=$uuid, name=$name, url=$url, username=$username, password=<redacted>>, error=$error, accountType=$accountType, isCollapsed=$isCollapsed, serverType=$serverType)"
} }
fun isTasksSubscription(context: Context): Boolean { fun isTasksSubscription(context: Context): Boolean {

@ -15,6 +15,7 @@ import org.tasks.R
import org.tasks.calendars.CalendarPicker import org.tasks.calendars.CalendarPicker
import org.tasks.calendars.CalendarPicker.Companion.newCalendarPicker import org.tasks.calendars.CalendarPicker.Companion.newCalendarPicker
import org.tasks.calendars.CalendarProvider import org.tasks.calendars.CalendarProvider
import org.tasks.data.CaldavAccount
import org.tasks.data.LocationDao import org.tasks.data.LocationDao
import org.tasks.data.Place import org.tasks.data.Place
import org.tasks.data.TagData import org.tasks.data.TagData
@ -84,7 +85,13 @@ class TaskDefaults : InjectingPreferenceFragment() {
.getStringValue(R.string.p_default_recurrence) .getStringValue(R.string.p_default_recurrence)
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
BasicRecurrenceDialog BasicRecurrenceDialog
.newBasicRecurrenceDialog(this, REQUEST_RECURRENCE, rrule, -1) .newBasicRecurrenceDialog(
target = this,
rc = REQUEST_RECURRENCE,
rrule = rrule,
dueDate = 0,
accountType = CaldavAccount.TYPE_LOCAL
)
.show(parentFragmentManager, FRAG_TAG_BASIC_RECURRENCE) .show(parentFragmentManager, FRAG_TAG_BASIC_RECURRENCE)
false false
} }

@ -3,7 +3,6 @@ package org.tasks.repeats
import android.app.Activity import android.app.Activity
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
import android.app.Dialog import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -13,11 +12,10 @@ import com.google.common.collect.Lists
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import net.fortuna.ical4j.model.Recur import net.fortuna.ical4j.model.Recur
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.DialogBuilder
import org.tasks.repeats.CustomRecurrenceActivity.Companion.EXTRA_ACCOUNT_TYPE
import org.tasks.repeats.CustomRecurrenceActivity.Companion.newIntent import org.tasks.repeats.CustomRecurrenceActivity.Companion.newIntent
import org.tasks.repeats.RecurrenceUtils.newRecur import org.tasks.repeats.RecurrenceUtils.newRecur
import org.tasks.time.DateTimeUtils.currentTimeMillis
import org.tasks.ui.SingleCheckedArrayAdapter import org.tasks.ui.SingleCheckedArrayAdapter
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -35,16 +33,18 @@ class BasicRecurrenceDialog : DialogFragment() {
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val arguments = arguments val args = requireArguments()
val dueDate = arguments!!.getLong(EXTRA_DATE, currentTimeMillis()) val rule = args.getString(EXTRA_RRULE)
val rule = arguments.getString(EXTRA_RRULE) val rrule =
var rrule: Recur? = null rule
.takeIf { !it.isNullOrBlank() }
?.let {
try { try {
if (!isNullOrEmpty(rule)) { newRecur(it)
rrule = newRecur(rule!!)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
null
}
} }
val customPicked = isCustomValue(rrule) val customPicked = isCustomValue(rrule)
val repeatOptions: List<String> = val repeatOptions: List<String> =
@ -64,40 +64,47 @@ class BasicRecurrenceDialog : DialogFragment() {
} }
return dialogBuilder return dialogBuilder
.newDialog() .newDialog()
.setSingleChoiceItems( .setSingleChoiceItems(adapter, selected) { dialog, selectedIndex: Int ->
adapter, val i = if (customPicked) {
selected if (selectedIndex == 0) {
) { dialogInterface: DialogInterface, i: Int -> dialog.dismiss()
var i = i
if (customPicked) {
if (i == 0) {
dialogInterface.dismiss()
return@setSingleChoiceItems return@setSingleChoiceItems
} }
i-- selectedIndex - 1
} else {
selectedIndex
} }
val result: Recur? val result = when (i) {
when (i) { 0 -> null
0 -> result = null
5 -> { 5 -> {
customRecurrence.launch(newIntent(requireContext(), rule, dueDate)) customRecurrence.launch(
newIntent(
context = requireContext(),
rrule = rule,
dueDate = args.getLong(EXTRA_DATE),
accountType = args.getInt(EXTRA_ACCOUNT_TYPE)
)
)
return@setSingleChoiceItems return@setSingleChoiceItems
} }
else -> { else -> {
result = newRecur() val frequency = when(i) {
result.interval = 1 1 -> Recur.Frequency.DAILY
when (i) { 2 -> Recur.Frequency.WEEKLY
1 -> result.setFrequency(Recur.Frequency.DAILY.name) 3 -> Recur.Frequency.MONTHLY
2 -> result.setFrequency(Recur.Frequency.WEEKLY.name) 4 -> Recur.Frequency.YEARLY
3 -> result.setFrequency(Recur.Frequency.MONTHLY.name) else -> throw IllegalArgumentException()
4 -> result.setFrequency(Recur.Frequency.YEARLY.name) }
newRecur().apply {
interval = 1
setFrequency(frequency.name)
} }
} }
} }
val intent = Intent() val intent = Intent()
intent.putExtra(EXTRA_RRULE, result?.toString()) intent.putExtra(EXTRA_RRULE, result?.toString())
targetFragment!!.onActivityResult(targetRequestCode, RESULT_OK, intent) targetFragment!!.onActivityResult(targetRequestCode, RESULT_OK, intent)
dialogInterface.dismiss() dialog.dismiss()
} }
.show() .show()
} }
@ -114,16 +121,19 @@ class BasicRecurrenceDialog : DialogFragment() {
const val EXTRA_RRULE = "extra_rrule" const val EXTRA_RRULE = "extra_rrule"
private const val EXTRA_DATE = "extra_date" private const val EXTRA_DATE = "extra_date"
fun newBasicRecurrenceDialog( fun newBasicRecurrenceDialog(
target: Fragment?, rc: Int, rrule: String?, dueDate: Long target: Fragment?,
rc: Int,
rrule: String?,
dueDate: Long,
accountType: Int,
): BasicRecurrenceDialog { ): BasicRecurrenceDialog {
val dialog = BasicRecurrenceDialog() val dialog = BasicRecurrenceDialog()
dialog.setTargetFragment(target, rc) dialog.setTargetFragment(target, rc)
val arguments = Bundle() dialog.arguments = Bundle().apply {
if (rrule != null) { rrule?.let { putString(EXTRA_RRULE, it) }
arguments.putString(EXTRA_RRULE, rrule) putLong(EXTRA_DATE, dueDate)
putInt(EXTRA_ACCOUNT_TYPE, accountType)
} }
arguments.putLong(EXTRA_DATE, dueDate)
dialog.arguments = arguments
return dialog return dialog
} }
} }

@ -5,8 +5,6 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.google.android.material.composethemeadapter.MdcTheme import com.google.android.material.composethemeadapter.MdcTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -43,11 +41,13 @@ class CustomRecurrenceActivity : FragmentActivity() {
companion object { companion object {
const val EXTRA_RRULE = "extra_rrule" const val EXTRA_RRULE = "extra_rrule"
const val EXTRA_DATE = "extra_date" const val EXTRA_DATE = "extra_date"
const val EXTRA_ACCOUNT_TYPE = "extra_account_type"
@JvmStatic @JvmStatic
fun newIntent(context: Context, rrule: String?, dueDate: Long) = fun newIntent(context: Context, rrule: String?, dueDate: Long, accountType: Int) =
Intent(context, CustomRecurrenceActivity::class.java) Intent(context, CustomRecurrenceActivity::class.java)
.putExtra(EXTRA_DATE, dueDate) .putExtra(EXTRA_DATE, dueDate)
.putExtra(EXTRA_RRULE, rrule) .putExtra(EXTRA_RRULE, rrule)
.putExtra(EXTRA_ACCOUNT_TYPE, accountType)
} }
} }

@ -17,7 +17,9 @@ import net.fortuna.ical4j.model.Recur.Frequency.YEARLY
import net.fortuna.ical4j.model.WeekDay import net.fortuna.ical4j.model.WeekDay
import net.fortuna.ical4j.model.WeekDayList import net.fortuna.ical4j.model.WeekDayList
import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RRule
import org.tasks.data.CaldavAccount.Companion.TYPE_MICROSOFT
import org.tasks.date.DateTimeUtils.toDateTime import org.tasks.date.DateTimeUtils.toDateTime
import org.tasks.repeats.CustomRecurrenceActivity.Companion.EXTRA_ACCOUNT_TYPE
import org.tasks.repeats.CustomRecurrenceActivity.Companion.EXTRA_DATE import org.tasks.repeats.CustomRecurrenceActivity.Companion.EXTRA_DATE
import org.tasks.repeats.CustomRecurrenceActivity.Companion.EXTRA_RRULE import org.tasks.repeats.CustomRecurrenceActivity.Companion.EXTRA_RRULE
import org.tasks.time.DateTime import org.tasks.time.DateTime
@ -43,11 +45,12 @@ class CustomRecurrenceViewModel @Inject constructor(
val endSelection: Int = 0, val endSelection: Int = 0,
val endDate: Long = dueDate.toDateTime().plusMonths(1).startOfDay().millis, val endDate: Long = dueDate.toDateTime().plusMonths(1).startOfDay().millis,
val endCount: Int = 1, val endCount: Int = 1,
val frequencyOptions: List<Recur.Frequency> = DEFAULT_FREQUENCIES, val frequencyOptions: List<Recur.Frequency> = FREQ_ALL,
val daysOfWeek: List<DayOfWeek> = Locale.getDefault().daysOfWeek(), val daysOfWeek: List<DayOfWeek> = Locale.getDefault().daysOfWeek(),
val selectedDays: List<DayOfWeek> = emptyList(), val selectedDays: List<DayOfWeek> = emptyList(),
val locale: Locale = Locale.getDefault(), val locale: Locale = Locale.getDefault(),
val monthDay: WeekDay? = null, val monthDay: WeekDay? = null,
val isMicrosoftTask: Boolean = false,
) { ) {
val dueDayOfWeek: DayOfWeek val dueDayOfWeek: DayOfWeek
get() = Instant.ofEpochMilli(dueDate).atZone(ZoneId.systemDefault()).dayOfWeek get() = Instant.ofEpochMilli(dueDate).atZone(ZoneId.systemDefault()).dayOfWeek
@ -78,17 +81,15 @@ class CustomRecurrenceViewModel @Inject constructor(
.get<Long>(EXTRA_DATE) .get<Long>(EXTRA_DATE)
?.takeIf { it > 0 } ?.takeIf { it > 0 }
?: System.currentTimeMillis().startOfDay() ?: System.currentTimeMillis().startOfDay()
val selectedDays = if (recur?.frequency == WEEKLY) { val isMicrosoftTask = savedStateHandle.get<Int>(EXTRA_ACCOUNT_TYPE) == TYPE_MICROSOFT
recur.dayList?.toDaysOfWeek() val frequencies = if (isMicrosoftTask) FREQ_MICROSOFT else FREQ_ALL
} else {
emptyList()
}
_state.update { state -> _state.update { state ->
state.copy( state.copy(
interval = recur?.interval?.takeIf { it > 0 } ?: 1, interval = recur?.interval?.takeIf { it > 0 } ?: 1,
frequency = recur?.frequency ?: WEEKLY, frequency = recur?.frequency?.takeIf { frequencies.contains(it) } ?: WEEKLY,
dueDate = dueDate, dueDate = dueDate,
endSelection = when { endSelection = when {
isMicrosoftTask -> 0
recur == null -> 0 recur == null -> 0
recur.until != null -> 1 recur.until != null -> 1
recur.count >= 0 -> 2 recur.count >= 0 -> 2
@ -103,7 +104,12 @@ class CustomRecurrenceViewModel @Inject constructor(
?.toDaysOfWeek() ?.toDaysOfWeek()
?: emptyList(), ?: emptyList(),
locale = locale, locale = locale,
monthDay = recur?.dayList?.takeIf { recur.frequency == MONTHLY }?.firstOrNull(), monthDay = recur
?.dayList
?.takeIf { recur.frequency == MONTHLY && !isMicrosoftTask }
?.firstOrNull(),
isMicrosoftTask = isMicrosoftTask,
frequencyOptions = frequencies,
) )
} }
} }
@ -182,7 +188,8 @@ class CustomRecurrenceViewModel @Inject constructor(
} }
companion object { companion object {
val DEFAULT_FREQUENCIES = listOf(MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY) val FREQ_ALL = listOf(MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY)
val FREQ_MICROSOFT = listOf(DAILY, WEEKLY, MONTHLY, YEARLY)
private fun Locale.daysOfWeek(): List<DayOfWeek> { private fun Locale.daysOfWeek(): List<DayOfWeek> {
val values = DayOfWeek.values() val values = DayOfWeek.values()

@ -1,10 +1,14 @@
package org.tasks.sync.microsoft package org.tasks.sync.microsoft
import com.todoroo.astrid.data.Task import com.todoroo.astrid.data.Task
import net.fortuna.ical4j.model.Recur
import net.fortuna.ical4j.model.WeekDay
import net.fortuna.ical4j.model.WeekDayList
import org.tasks.data.CaldavTask import org.tasks.data.CaldavTask
import org.tasks.data.TagData import org.tasks.data.TagData
import org.tasks.sync.microsoft.Tasks.Task.RecurrenceDayOfWeek
import org.tasks.sync.microsoft.Tasks.Task.RecurrenceType
import org.tasks.time.DateTime import org.tasks.time.DateTime
import org.tasks.time.DateTimeUtils.startOfDay
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.util.Locale import java.util.Locale
@ -13,6 +17,8 @@ import java.util.TimeZone
object MicrosoftConverter { object MicrosoftConverter {
private const val TYPE_TEXT = "text" private const val TYPE_TEXT = "text"
private const val DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS0000"
private const val DATE_TIME_UTC_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS0000'Z'"
fun Task.applyRemote( fun Task.applyRemote(
remote: Tasks.Task, remote: Tasks.Task,
@ -30,8 +36,34 @@ object MicrosoftConverter {
dueDate = remote.dueDateTime.toLong(0L) dueDate = remote.dueDateTime.toLong(0L)
creationDate = remote.createdDateTime.parseDateTime() creationDate = remote.createdDateTime.parseDateTime()
modificationDate = remote.lastModifiedDateTime.parseDateTime() modificationDate = remote.lastModifiedDateTime.parseDateTime()
recurrence = remote.recurrence?.let { recurrence ->
val pattern = recurrence.pattern
val frequency = when (pattern.type) {
RecurrenceType.daily -> Recur.Frequency.DAILY
RecurrenceType.weekly -> Recur.Frequency.WEEKLY
RecurrenceType.absoluteMonthly -> Recur.Frequency.MONTHLY
RecurrenceType.absoluteYearly -> Recur.Frequency.YEARLY
else -> return@let null
}
val dayList = pattern.daysOfWeek.mapNotNull {
when (it) {
RecurrenceDayOfWeek.sunday -> WeekDay.SU
RecurrenceDayOfWeek.monday -> WeekDay.MO
RecurrenceDayOfWeek.tuesday -> WeekDay.TU
RecurrenceDayOfWeek.wednesday -> WeekDay.WE
RecurrenceDayOfWeek.thursday -> WeekDay.TH
RecurrenceDayOfWeek.friday -> WeekDay.FR
RecurrenceDayOfWeek.saturday -> WeekDay.SA
}
}
Recur.Builder()
.frequency(frequency)
.interval(pattern.interval.takeIf { it > 1 })
.dayList(WeekDayList(*dayList.toTypedArray()))
.build()
.toString()
}
// checklist to subtasks // checklist to subtasks
// repeat
// sync reminders // sync reminders
// sync files // sync files
} }
@ -59,24 +91,72 @@ object MicrosoftConverter {
categories = tags.map { it.name!! }.takeIf { it.isNotEmpty() }, categories = tags.map { it.name!! }.takeIf { it.isNotEmpty() },
dueDateTime = if (hasDueDate()) { dueDateTime = if (hasDueDate()) {
Tasks.Task.DateTime( Tasks.Task.DateTime(
dateTime = DateTime(dueDate.startOfDay()).toUTC().toString("yyyy-MM-dd'T'HH:mm:ss.SSS0000"), dateTime = DateTime(dueDate).startOfDay().toUTC().toString(DATE_TIME_FORMAT),
timeZone = "UTC"
)
} else if (isRecurring) {
Tasks.Task.DateTime(
dateTime = DateTime().startOfDay().toUTC().toString(DATE_TIME_FORMAT),
timeZone = "UTC" timeZone = "UTC"
) )
} else { } else {
null null
}, },
lastModifiedDateTime = DateTime(modificationDate).toUTC().toString("yyyy-MM-dd'T'HH:mm:ss.SSS0000'Z'"), lastModifiedDateTime = DateTime(modificationDate).toUTC().toString(DATE_TIME_UTC_FORMAT),
createdDateTime = DateTime(creationDate).toUTC().toString("yyyy-MM-dd'T'HH:mm:ss.SSS0000'Z'"), createdDateTime = DateTime(creationDate).toUTC().toString(DATE_TIME_UTC_FORMAT),
completedDateTime = if (isCompleted) { completedDateTime = if (isCompleted) {
Tasks.Task.DateTime( Tasks.Task.DateTime(
dateTime = DateTime(completionDate).toUTC() dateTime = DateTime(completionDate).toUTC().toString(DATE_TIME_FORMAT),
.toString("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS"),
timeZone = "UTC", timeZone = "UTC",
) )
} else { } else {
null null
}, },
recurrence = if (isRecurring) {
val recur = Recur(recurrence)
when (recur.frequency) {
Recur.Frequency.DAILY -> RecurrenceType.daily
Recur.Frequency.WEEKLY -> RecurrenceType.weekly
Recur.Frequency.MONTHLY -> RecurrenceType.absoluteMonthly
Recur.Frequency.YEARLY -> RecurrenceType.absoluteYearly
else -> null
}?.let { frequency ->
val dueDateTime = if (hasDueDate()) DateTime(dueDate) else DateTime()
Tasks.Task.Recurrence(
pattern = Tasks.Task.Pattern(
type = frequency,
interval = recur.interval.coerceAtLeast(1),
daysOfWeek = recur.dayList.mapNotNull {
when (it) {
WeekDay.SU -> RecurrenceDayOfWeek.sunday
WeekDay.MO -> RecurrenceDayOfWeek.monday
WeekDay.TU -> RecurrenceDayOfWeek.tuesday
WeekDay.WE -> RecurrenceDayOfWeek.wednesday
WeekDay.TH -> RecurrenceDayOfWeek.thursday
WeekDay.FR -> RecurrenceDayOfWeek.friday
WeekDay.SA -> RecurrenceDayOfWeek.saturday
else -> null
}
},
month = when (frequency) {
RecurrenceType.absoluteYearly -> dueDateTime.monthOfYear
else -> 0
},
dayOfMonth = when (frequency) {
RecurrenceType.absoluteYearly,
RecurrenceType.absoluteMonthly -> dueDateTime.dayOfMonth
else -> 0
}
),
)
}
} else {
null
}
// subtasks to checklist
// isReminderOn = // isReminderOn =
// reminders
// files
) )
} }

@ -21,6 +21,7 @@ data class Tasks(
val completedDateTime: DateTime? = null, val completedDateTime: DateTime? = null,
val dueDateTime: DateTime? = null, val dueDateTime: DateTime? = null,
val linkedResources: List<LinkedResource>? = null, val linkedResources: List<LinkedResource>? = null,
val recurrence: Recurrence? = null,
@field:Json(name = "@removed") val removed: Removed? = null, @field:Json(name = "@removed") val removed: Removed? = null,
) { ) {
data class Body( data class Body(
@ -44,6 +45,47 @@ data class Tasks(
val timeZone: String, val timeZone: String,
) )
data class Recurrence(
val pattern: Pattern,
)
data class Pattern(
val type: RecurrenceType,
val interval: Int,
val month: Int = 0,
val dayOfMonth: Int = 0,
val daysOfWeek: List<RecurrenceDayOfWeek>,
val firstDayOfWeek: RecurrenceDayOfWeek = RecurrenceDayOfWeek.sunday,
val index: RecurrenceIndex = RecurrenceIndex.first,
)
enum class RecurrenceType {
daily,
weekly,
absoluteMonthly,
relativeMonthly,
absoluteYearly,
relativeYearly,
}
enum class RecurrenceIndex {
first,
second,
third,
fourth,
last,
}
enum class RecurrenceDayOfWeek {
sunday,
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
}
enum class Importance { enum class Importance {
low, low,
normal, normal,

@ -96,4 +96,36 @@ class ConvertFromMicrosoftTests {
) )
} }
} }
@Test
fun parseDailyRecurrence() {
withTZ("America/Chicago") {
val (local, _) = TestUtilities.mstodo("microsoft/repeat_daily.txt")
assertEquals("FREQ=DAILY", local.recurrence)
}
}
@Test
fun parseWeekdayRecurrence() {
withTZ("America/Chicago") {
val (local, _) = TestUtilities.mstodo("microsoft/repeat_weekdays.txt")
assertEquals("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU,WE,TH,FR", local.recurrence)
}
}
@Test
fun parseAbsoluteMonthlyRecurrence() {
withTZ("America/Chicago") {
val (local, _) = TestUtilities.mstodo("microsoft/repeat_monthly.txt")
assertEquals("FREQ=MONTHLY", local.recurrence)
}
}
@Test
fun parseAbsoluteYearlyRecurrence() {
withTZ("America/Chicago") {
val (local, _) = TestUtilities.mstodo("microsoft/repeat_yearly.txt")
assertEquals("FREQ=YEARLY", local.recurrence)
}
}
} }

@ -4,8 +4,11 @@ import com.natpryce.makeiteasy.MakeItEasy.with
import com.todoroo.astrid.data.Task import com.todoroo.astrid.data.Task
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.tasks.Freeze.Companion.freezeAt
import org.tasks.TestUtilities.withTZ import org.tasks.TestUtilities.withTZ
import org.tasks.data.CaldavTask
import org.tasks.makers.CaldavTaskMaker.REMOTE_ID import org.tasks.makers.CaldavTaskMaker.REMOTE_ID
import org.tasks.makers.CaldavTaskMaker.newCaldavTask import org.tasks.makers.CaldavTaskMaker.newCaldavTask
import org.tasks.makers.TagDataMaker.NAME import org.tasks.makers.TagDataMaker.NAME
@ -14,92 +17,81 @@ import org.tasks.makers.TaskMaker.COMPLETION_TIME
import org.tasks.makers.TaskMaker.DESCRIPTION import org.tasks.makers.TaskMaker.DESCRIPTION
import org.tasks.makers.TaskMaker.DUE_TIME import org.tasks.makers.TaskMaker.DUE_TIME
import org.tasks.makers.TaskMaker.PRIORITY import org.tasks.makers.TaskMaker.PRIORITY
import org.tasks.makers.TaskMaker.RECUR
import org.tasks.makers.TaskMaker.TITLE import org.tasks.makers.TaskMaker.TITLE
import org.tasks.makers.TaskMaker.newTask import org.tasks.makers.TaskMaker.newTask
import org.tasks.sync.microsoft.MicrosoftConverter.toRemote import org.tasks.sync.microsoft.MicrosoftConverter.toRemote
import org.tasks.sync.microsoft.Tasks.Task.Importance import org.tasks.sync.microsoft.Tasks.Task.Importance
import org.tasks.sync.microsoft.Tasks.Task.RecurrenceDayOfWeek
import org.tasks.sync.microsoft.Tasks.Task.RecurrenceType
import org.tasks.time.DateTime import org.tasks.time.DateTime
class ConvertToMicrosoftTests { class ConvertToMicrosoftTests {
@Test @Test
fun noIdForNewTask() { fun noIdForNewTask() {
val remote = val remote = newTask().toRemote(newCaldavTask(with(REMOTE_ID, null as String?)))
newTask().toRemote(newCaldavTask(with(REMOTE_ID, null as String?)), emptyList())
assertNull(remote.id) assertNull(remote.id)
} }
@Test @Test
fun setTitle() { fun setTitle() {
val remote = val remote = newTask(with(TITLE, "title")).toRemote(newCaldavTask())
newTask(with(TITLE, "title")).toRemote(newCaldavTask(), emptyList())
assertEquals("title", remote.title) assertEquals("title", remote.title)
} }
@Test @Test
fun noBody() { fun noBody() {
val remote = val remote = newTask(with(DESCRIPTION, null as String?)).toRemote()
newTask(with(DESCRIPTION, null as String?))
.toRemote(newCaldavTask(), emptyList())
assertNull(remote.body) assertNull(remote.body)
} }
@Test @Test
fun setBody() { fun setBody() {
val remote = val remote = newTask(with(DESCRIPTION, "Description")).toRemote()
newTask(with(DESCRIPTION, "Description"))
.toRemote(newCaldavTask(), emptyList())
assertEquals("Description", remote.body?.content) assertEquals("Description", remote.body?.content)
assertEquals("text", remote.body?.contentType) assertEquals("text", remote.body?.contentType)
} }
@Test @Test
fun setHighPriority() { fun setHighPriority() {
val remote = val remote = newTask(with(PRIORITY, Task.Priority.HIGH)).toRemote()
newTask(with(PRIORITY, Task.Priority.HIGH))
.toRemote(newCaldavTask(), emptyList())
assertEquals(Importance.high, remote.importance) assertEquals(Importance.high, remote.importance)
} }
@Test @Test
fun setNormalPriority() { fun setNormalPriority() {
val remote = val remote = newTask(with(PRIORITY, Task.Priority.MEDIUM)).toRemote()
newTask(with(PRIORITY, Task.Priority.MEDIUM))
.toRemote(newCaldavTask(), emptyList())
assertEquals(Importance.normal, remote.importance) assertEquals(Importance.normal, remote.importance)
} }
@Test @Test
fun setLowPriority() { fun setLowPriority() {
val remote = val remote = newTask(with(PRIORITY, Task.Priority.LOW)).toRemote()
newTask(with(PRIORITY, Task.Priority.LOW))
.toRemote(newCaldavTask(), emptyList())
assertEquals(Importance.low, remote.importance) assertEquals(Importance.low, remote.importance)
} }
@Test @Test
fun setNoPriorityToLow() { fun setNoPriorityToLow() {
val remote = val remote = newTask(with(PRIORITY, Task.Priority.NONE)).toRemote()
newTask(with(PRIORITY, Task.Priority.NONE))
.toRemote(newCaldavTask(), emptyList())
assertEquals(Importance.low, remote.importance) assertEquals(Importance.low, remote.importance)
} }
@Test @Test
fun statusForUncompletedTask() { fun statusForUncompletedTask() {
val remote = newTask().toRemote(newCaldavTask(), emptyList()) val remote = newTask().toRemote()
assertEquals(Tasks.Task.Status.notStarted, remote.status) assertEquals(Tasks.Task.Status.notStarted, remote.status)
} }
@Test @Test
fun statusForCompletedTask() { fun statusForCompletedTask() {
val remote = val remote =
newTask(with(COMPLETION_TIME, DateTime())).toRemote(newCaldavTask(), emptyList()) newTask(with(COMPLETION_TIME, DateTime())).toRemote()
assertEquals(Tasks.Task.Status.completed, remote.status) assertEquals(Tasks.Task.Status.completed, remote.status)
} }
@Test @Test
fun noCategories() { fun noCategories() {
val remote = newTask().toRemote(newCaldavTask(), emptyList()) val remote = newTask().toRemote()
assertNull(remote.categories) assertNull(remote.categories)
} }
@ -120,7 +112,7 @@ class ConvertToMicrosoftTests {
withTZ("America/Chicago") { withTZ("America/Chicago") {
val remote = Task( val remote = Task(
creationDate = DateTime(2023, 7, 21, 0, 42, 13, 475).millis, creationDate = DateTime(2023, 7, 21, 0, 42, 13, 475).millis,
).toRemote(newCaldavTask(), emptyList()) ).toRemote()
assertEquals( assertEquals(
"2023-07-21T05:42:13.4750000Z", "2023-07-21T05:42:13.4750000Z",
remote.createdDateTime remote.createdDateTime
@ -133,7 +125,7 @@ class ConvertToMicrosoftTests {
withTZ("America/Chicago") { withTZ("America/Chicago") {
val remote = Task( val remote = Task(
modificationDate = DateTime(2023, 7, 21, 0, 49, 4, 3).millis, modificationDate = DateTime(2023, 7, 21, 0, 49, 4, 3).millis,
).toRemote(newCaldavTask(), emptyList()) ).toRemote()
assertEquals( assertEquals(
"2023-07-21T05:49:04.0030000Z", "2023-07-21T05:49:04.0030000Z",
remote.lastModifiedDateTime remote.lastModifiedDateTime
@ -145,14 +137,103 @@ class ConvertToMicrosoftTests {
fun setDueDateTime() { fun setDueDateTime() {
withTZ("America/Chicago") { withTZ("America/Chicago") {
val remote = newTask( val remote = newTask(
with( with(DUE_TIME, DateTime(2023, 7, 21, 13, 30))
DUE_TIME,
DateTime(2023, 7, 21, 13, 30)
)
) )
.toRemote(newCaldavTask(), emptyList()) .toRemote()
assertEquals("2023-07-21T05:00:00.0000000", remote.dueDateTime?.dateTime) assertEquals("2023-07-21T05:00:00.0000000", remote.dueDateTime?.dateTime)
assertEquals("UTC", remote.dueDateTime?.timeZone) assertEquals("UTC", remote.dueDateTime?.timeZone)
} }
} }
@Test
fun setDailyRecurrence() {
withTZ("America/Chicago") {
val remote = newTask(
with(DUE_TIME, DateTime(2023, 8, 2, 22, 42, 59)),
with(RECUR, "FREQ=DAILY")
)
.toRemote()
.recurrence
?: throw IllegalStateException()
assertEquals(RecurrenceType.daily, remote.pattern.type)
assertEquals(1, remote.pattern.interval)
assertTrue(remote.pattern.daysOfWeek.isEmpty())
}
}
@Test
fun setWeeklyRecurrence() {
withTZ("America/Chicago") {
val remote = newTask(
with(DUE_TIME, DateTime(2023, 7, 31, 0, 26, 48)),
with(RECUR, "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU,WE,TH,FR")
)
.toRemote()
.recurrence
?: throw IllegalStateException()
assertEquals(RecurrenceType.weekly, remote.pattern.type)
assertEquals(2, remote.pattern.interval)
assertEquals(
listOf(
RecurrenceDayOfWeek.monday,
RecurrenceDayOfWeek.tuesday,
RecurrenceDayOfWeek.wednesday,
RecurrenceDayOfWeek.thursday,
RecurrenceDayOfWeek.friday,
),
remote.pattern.daysOfWeek
)
}
}
@Test
fun setMonthlyRecurrence() {
withTZ("America/Chicago") {
val remote = newTask(
with(DUE_TIME, DateTime(2023, 7, 31, 0, 26, 48)),
with(RECUR, "FREQ=MONTHLY")
)
.toRemote()
.recurrence
?: throw IllegalStateException()
assertEquals(RecurrenceType.absoluteMonthly, remote.pattern.type)
assertEquals(31, remote.pattern.dayOfMonth)
assertEquals(1, remote.pattern.interval)
}
}
@Test
fun setAnnualRecurrence() {
withTZ("America/Chicago") {
val remote = newTask(
with(DUE_TIME, DateTime(2023, 8, 2, 23, 9, 7)),
with(RECUR, "FREQ=YEARLY")
)
.toRemote()
.recurrence
?: throw IllegalStateException()
assertEquals(RecurrenceType.absoluteYearly, remote.pattern.type)
assertEquals(8, remote.pattern.month)
assertEquals(2, remote.pattern.dayOfMonth)
assertEquals(1, remote.pattern.interval)
}
}
@Test
fun setAnnualRecurrenceWithoutDueDate() {
withTZ("America/Chicago") {
freezeAt(DateTime(2023, 8, 2, 23, 17, 22).millis) {
val remote = newTask(
with(RECUR, "FREQ=YEARLY")
)
.toRemote()
.recurrence
?: throw IllegalStateException()
assertEquals(8, remote.pattern.month)
assertEquals(2, remote.pattern.dayOfMonth)
}
}
}
private fun Task.toRemote(caldavTask: CaldavTask = newCaldavTask()) = toRemote(caldavTask, emptyList())
} }

@ -0,0 +1,45 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(todoTask)",
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/todo/lists/AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoALgAAA8dKmrSa60tBjeiKoPukmoQBAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAA==/tasks/delta?$deltatoken=l7WI41swwioT5csv4k99noD3PiMxiCD5PIupo55YsiR-aZXAnrnNZiW0yD_OdiovDJMhDsVJJ_ti6QJ2sP4FKE-qI2-ZJCLm23O7AMamOLI.S4E74O9X-kqa291QVygbM6goWhVLeQfJ17LMdlbYFpE",
"value": [
{
"@odata.type": "#microsoft.graph.todoTask",
"@odata.etag": "W/\"SRPGnpbHYES1XW8UlYrtsgAGZvj3Hg==\"",
"importance": "normal",
"isReminderOn": false,
"status": "notStarted",
"title": "Test daily",
"createdDateTime": "2023-08-02T04:36:07.3165141Z",
"lastModifiedDateTime": "2023-08-02T04:36:07.3577757Z",
"hasAttachments": false,
"categories": [],
"id": "AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoARgAAA8dKmrSa60tBjeiKoPukmoQHAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAEkTxp6Wx2BEtV1vFJWK7bIABmbYjnwAAAA=",
"body": {
"content": "",
"contentType": "text"
},
"dueDateTime": {
"dateTime": "2023-08-01T05:00:00.0000000",
"timeZone": "UTC"
},
"recurrence": {
"pattern": {
"type": "daily",
"interval": 1,
"month": 0,
"dayOfMonth": 0,
"daysOfWeek": [],
"firstDayOfWeek": "sunday",
"index": "first"
},
"range": {
"type": "noEnd",
"startDate": "2023-08-01",
"endDate": "0001-01-01",
"recurrenceTimeZone": "UTC",
"numberOfOccurrences": 0
}
}
}
]
}

@ -0,0 +1,45 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(todoTask)",
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/todo/lists/AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoALgAAA8dKmrSa60tBjeiKoPukmoQBAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAA==/tasks/delta?$deltatoken=l7WI41swwioT5csv4k99ngHvnDU_spnidrOYzb78--8L_CvTDIAkbXl6MuXT-elf9lo4mE3OVyH8kpKh-qhqlQvZ5QTayyCCCGunhVPj6Fo.mekQONzhwcEJiA_ZH8hSylfMUnFwnJ4iTjTdLny0tqw",
"value": [
{
"@odata.type": "#microsoft.graph.todoTask",
"@odata.etag": "W/\"SRPGnpbHYES1XW8UlYrtsgAGZZbnFw==\"",
"importance": "normal",
"isReminderOn": false,
"status": "notStarted",
"title": "Test monthly",
"createdDateTime": "2023-07-31T06:02:41.6818553Z",
"lastModifiedDateTime": "2023-07-31T06:02:41.7317004Z",
"hasAttachments": false,
"categories": [],
"id": "AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoARgAAA8dKmrSa60tBjeiKoPukmoQHAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAEkTxp6Wx2BEtV1vFJWK7bIABmV2YREAAAA=",
"body": {
"content": "",
"contentType": "text"
},
"dueDateTime": {
"dateTime": "2023-08-07T05:00:00.0000000",
"timeZone": "UTC"
},
"recurrence": {
"pattern": {
"type": "absoluteMonthly",
"interval": 1,
"month": 0,
"dayOfMonth": 7,
"daysOfWeek": [],
"firstDayOfWeek": "sunday",
"index": "first"
},
"range": {
"type": "noEnd",
"startDate": "2023-08-07",
"endDate": "0001-01-01",
"recurrenceTimeZone": "UTC",
"numberOfOccurrences": 0
}
}
}
]
}

@ -0,0 +1,51 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(todoTask)",
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/todo/lists/AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoALgAAA8dKmrSa60tBjeiKoPukmoQBAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAA==/tasks/delta?$deltatoken=l7WI41swwioT5csv4k99ngHvnDU_spnidrOYzb78--8L_CvTDIAkbXl6MuXT-elfDvxAaStXSRqnXHi-F76OGkTL9CQOnOhkx-vCewPuUdY.XsfDMIxn_aFfN1pQ1QjiU4-UJBzkm7SbIdTgPm-Ie38",
"value": [
{
"@odata.type": "#microsoft.graph.todoTask",
"@odata.etag": "W/\"SRPGnpbHYES1XW8UlYrtsgAGZZbjOA==\"",
"importance": "normal",
"isReminderOn": false,
"status": "notStarted",
"title": "Test weekdays",
"createdDateTime": "2023-07-31T04:49:33.0062602Z",
"lastModifiedDateTime": "2023-07-31T04:49:33.0698644Z",
"hasAttachments": false,
"categories": [],
"id": "AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoARgAAA8dKmrSa60tBjeiKoPukmoQHAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAEkTxp6Wx2BEtV1vFJWK7bIABmV2YRAAAAA=",
"body": {
"content": "",
"contentType": "text"
},
"dueDateTime": {
"dateTime": "2023-07-31T05:00:00.0000000",
"timeZone": "UTC"
},
"recurrence": {
"pattern": {
"type": "weekly",
"interval": 2,
"month": 0,
"dayOfMonth": 0,
"daysOfWeek": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday"
],
"firstDayOfWeek": "sunday",
"index": "first"
},
"range": {
"type": "noEnd",
"startDate": "2023-07-31",
"endDate": "0001-01-01",
"recurrenceTimeZone": "UTC",
"numberOfOccurrences": 0
}
}
}
]
}

@ -0,0 +1,45 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(todoTask)",
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/todo/lists/AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoALgAAA8dKmrSa60tBjeiKoPukmoQBAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAA==/tasks/delta?$deltatoken=l7WI41swwioT5csv4k99noD3PiMxiCD5PIupo55YsiR-aZXAnrnNZiW0yD_OdiovZPu9JSxeTq-fQqLN4UmEJp9Tr7du6pzeTXABpwX5LG4.6R96g2GwE6zj23ydupR0eYB92e6ta7VjtqgrYOn4d9k",
"value": [
{
"@odata.type": "#microsoft.graph.todoTask",
"@odata.etag": "W/\"SRPGnpbHYES1XW8UlYrtsgAGZvj3Eg==\"",
"importance": "normal",
"isReminderOn": false,
"status": "notStarted",
"title": "Test yearly",
"createdDateTime": "2023-08-02T04:34:29.5965057Z",
"lastModifiedDateTime": "2023-08-02T04:34:29.6508837Z",
"hasAttachments": false,
"categories": [],
"id": "AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoARgAAA8dKmrSa60tBjeiKoPukmoQHAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAEkTxp6Wx2BEtV1vFJWK7bIABmbYjnsAAAA=",
"body": {
"content": "",
"contentType": "text"
},
"dueDateTime": {
"dateTime": "2023-08-01T05:00:00.0000000",
"timeZone": "UTC"
},
"recurrence": {
"pattern": {
"type": "absoluteYearly",
"interval": 1,
"month": 8,
"dayOfMonth": 1,
"daysOfWeek": [],
"firstDayOfWeek": "sunday",
"index": "first"
},
"range": {
"type": "noEnd",
"startDate": "2023-08-01",
"endDate": "0001-01-01",
"recurrenceTimeZone": "UTC",
"numberOfOccurrences": 0
}
}
}
]
}
Loading…
Cancel
Save