diff --git a/app/src/main/java/com/todoroo/astrid/data/Task.kt b/app/src/main/java/com/todoroo/astrid/data/Task.kt index 2aa3590b5..a195984e7 100644 --- a/app/src/main/java/com/todoroo/astrid/data/Task.kt +++ b/app/src/main/java/com/todoroo/astrid/data/Task.kt @@ -155,7 +155,7 @@ data class Task( } val isRecurring: Boolean - get() = !Strings.isNullOrEmpty(recurrence) + get() = recurrence?.isNotBlank() == true fun setRecurrence(rrule: Recur?) { recurrence = rrule?.toString() diff --git a/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt b/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt index fcbca00ae..ba29c2d0c 100644 --- a/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt @@ -13,12 +13,17 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.lifecycleScope 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 kotlinx.coroutines.launch import net.fortuna.ical4j.model.Recur import net.fortuna.ical4j.model.WeekDay import org.tasks.R import org.tasks.compose.collectAsStateLifecycleAware 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.RecurrenceUtils.newRecur import org.tasks.repeats.RepeatRuleToString @@ -30,6 +35,7 @@ import javax.inject.Inject @AndroidEntryPoint class RepeatControlSet : TaskEditControlFragment() { @Inject lateinit var repeatRuleToString: RepeatRuleToString + @Inject lateinit var caldavDao: CaldavDao override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQUEST_RECURRENCE) { @@ -86,13 +92,27 @@ class RepeatControlSet : TaskEditControlFragment() { }, repeatAfterCompletion = viewModel.repeatAfterCompletion.collectAsStateLifecycleAware().value, onClick = { - BasicRecurrenceDialog.newBasicRecurrenceDialog( - this@RepeatControlSet, - REQUEST_RECURRENCE, - viewModel.recurrence.value, - viewModel.dueDate.value.let { if (it > 0) it else currentTimeMillis() } - ) - .show(parentFragmentManager, FRAG_TAG_BASIC_RECURRENCE) + 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( + target = this@RepeatControlSet, + rc = REQUEST_RECURRENCE, + rrule = viewModel.recurrence.value, + dueDate = viewModel.dueDate.value, + accountType = accountType, + ) + .show(parentFragmentManager, FRAG_TAG_BASIC_RECURRENCE) + } }, onRepeatFromChanged = { viewModel.repeatAfterCompletion.value = it } ) diff --git a/app/src/main/java/org/tasks/compose/pickers/CustomRecurrence.kt b/app/src/main/java/org/tasks/compose/pickers/CustomRecurrence.kt index 5df8d3162..584c8544f 100644 --- a/app/src/main/java/org/tasks/compose/pickers/CustomRecurrence.kt +++ b/app/src/main/java/org/tasks/compose/pickers/CustomRecurrence.kt @@ -156,7 +156,7 @@ fun CustomRecurrence( selected = state.selectedDays, toggle = toggleDay, ) - } else if (state.frequency == Recur.Frequency.MONTHLY) { + } else if (state.frequency == Recur.Frequency.MONTHLY && !state.isMicrosoftTask) { MonthlyPicker( monthDay = state.monthDay, dayNumber = state.dueDayOfMonth, @@ -167,18 +167,20 @@ fun CustomRecurrence( onSelected = setMonthSelection, ) } - Divider( - modifier = Modifier.padding(vertical = if (state.frequency == Recur.Frequency.WEEKLY) 11.dp else 16.dp), - color = border() - ) - EndsPicker( - selection = state.endSelection, - endDate = state.endDate, - endOccurrences = state.endCount, - setEndDate = setEndDate, - setSelection = setSelectedEndType, - setOccurrences = setOccurrences, - ) + if (!state.isMicrosoftTask) { + Divider( + modifier = Modifier.padding(vertical = if (state.frequency == Recur.Frequency.WEEKLY) 11.dp else 16.dp), + color = border() + ) + EndsPicker( + selection = state.endSelection, + endDate = state.endDate, + endOccurrences = state.endCount, + setEndDate = setEndDate, + setSelection = setSelectedEndType, + setOccurrences = setOccurrences, + ) + } } } } diff --git a/app/src/main/java/org/tasks/data/CaldavAccount.kt b/app/src/main/java/org/tasks/data/CaldavAccount.kt index 8b622face..e4a2f6c93 100644 --- a/app/src/main/java/org/tasks/data/CaldavAccount.kt +++ b/app/src/main/java/org/tasks/data/CaldavAccount.kt @@ -185,7 +185,7 @@ class CaldavAccount : Parcelable { } 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=>, error=$error, accountType=$accountType, isCollapsed=$isCollapsed, serverType=$serverType)" } fun isTasksSubscription(context: Context): Boolean { diff --git a/app/src/main/java/org/tasks/preferences/fragments/TaskDefaults.kt b/app/src/main/java/org/tasks/preferences/fragments/TaskDefaults.kt index a7de78717..c87f2dd0a 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/TaskDefaults.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/TaskDefaults.kt @@ -15,6 +15,7 @@ import org.tasks.R import org.tasks.calendars.CalendarPicker import org.tasks.calendars.CalendarPicker.Companion.newCalendarPicker import org.tasks.calendars.CalendarProvider +import org.tasks.data.CaldavAccount import org.tasks.data.LocationDao import org.tasks.data.Place import org.tasks.data.TagData @@ -84,7 +85,13 @@ class TaskDefaults : InjectingPreferenceFragment() { .getStringValue(R.string.p_default_recurrence) ?.takeIf { it.isNotBlank() } 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) false } diff --git a/app/src/main/java/org/tasks/repeats/BasicRecurrenceDialog.kt b/app/src/main/java/org/tasks/repeats/BasicRecurrenceDialog.kt index 6acfe48b3..8a08a06cf 100644 --- a/app/src/main/java/org/tasks/repeats/BasicRecurrenceDialog.kt +++ b/app/src/main/java/org/tasks/repeats/BasicRecurrenceDialog.kt @@ -3,7 +3,6 @@ package org.tasks.repeats import android.app.Activity import android.app.Activity.RESULT_OK import android.app.Dialog -import android.content.DialogInterface import android.content.Intent import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts @@ -13,11 +12,10 @@ import com.google.common.collect.Lists import dagger.hilt.android.AndroidEntryPoint import net.fortuna.ical4j.model.Recur import org.tasks.R -import org.tasks.Strings.isNullOrEmpty 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.RecurrenceUtils.newRecur -import org.tasks.time.DateTimeUtils.currentTimeMillis import org.tasks.ui.SingleCheckedArrayAdapter import timber.log.Timber import javax.inject.Inject @@ -35,17 +33,19 @@ class BasicRecurrenceDialog : DialogFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val arguments = arguments - val dueDate = arguments!!.getLong(EXTRA_DATE, currentTimeMillis()) - val rule = arguments.getString(EXTRA_RRULE) - var rrule: Recur? = null - try { - if (!isNullOrEmpty(rule)) { - rrule = newRecur(rule!!) - } - } catch (e: Exception) { - Timber.e(e) - } + val args = requireArguments() + val rule = args.getString(EXTRA_RRULE) + val rrule = + rule + .takeIf { !it.isNullOrBlank() } + ?.let { + try { + newRecur(it) + } catch (e: Exception) { + Timber.e(e) + null + } + } val customPicked = isCustomValue(rrule) val repeatOptions: List = Lists.newArrayList(*requireContext().resources.getStringArray(R.array.repeat_options)) @@ -64,40 +64,47 @@ class BasicRecurrenceDialog : DialogFragment() { } return dialogBuilder .newDialog() - .setSingleChoiceItems( - adapter, - selected - ) { dialogInterface: DialogInterface, i: Int -> - var i = i - if (customPicked) { - if (i == 0) { - dialogInterface.dismiss() + .setSingleChoiceItems(adapter, selected) { dialog, selectedIndex: Int -> + val i = if (customPicked) { + if (selectedIndex == 0) { + dialog.dismiss() return@setSingleChoiceItems } - i-- + selectedIndex - 1 + } else { + selectedIndex } - val result: Recur? - when (i) { - 0 -> result = null + val result = when (i) { + 0 -> null 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 } else -> { - result = newRecur() - result.interval = 1 - when (i) { - 1 -> result.setFrequency(Recur.Frequency.DAILY.name) - 2 -> result.setFrequency(Recur.Frequency.WEEKLY.name) - 3 -> result.setFrequency(Recur.Frequency.MONTHLY.name) - 4 -> result.setFrequency(Recur.Frequency.YEARLY.name) + val frequency = when(i) { + 1 -> Recur.Frequency.DAILY + 2 -> Recur.Frequency.WEEKLY + 3 -> Recur.Frequency.MONTHLY + 4 -> Recur.Frequency.YEARLY + else -> throw IllegalArgumentException() + } + newRecur().apply { + interval = 1 + setFrequency(frequency.name) } } } val intent = Intent() intent.putExtra(EXTRA_RRULE, result?.toString()) targetFragment!!.onActivityResult(targetRequestCode, RESULT_OK, intent) - dialogInterface.dismiss() + dialog.dismiss() } .show() } @@ -114,16 +121,19 @@ class BasicRecurrenceDialog : DialogFragment() { const val EXTRA_RRULE = "extra_rrule" private const val EXTRA_DATE = "extra_date" fun newBasicRecurrenceDialog( - target: Fragment?, rc: Int, rrule: String?, dueDate: Long + target: Fragment?, + rc: Int, + rrule: String?, + dueDate: Long, + accountType: Int, ): BasicRecurrenceDialog { val dialog = BasicRecurrenceDialog() dialog.setTargetFragment(target, rc) - val arguments = Bundle() - if (rrule != null) { - arguments.putString(EXTRA_RRULE, rrule) + dialog.arguments = Bundle().apply { + rrule?.let { putString(EXTRA_RRULE, it) } + putLong(EXTRA_DATE, dueDate) + putInt(EXTRA_ACCOUNT_TYPE, accountType) } - arguments.putLong(EXTRA_DATE, dueDate) - dialog.arguments = arguments return dialog } } diff --git a/app/src/main/java/org/tasks/repeats/CustomRecurrenceActivity.kt b/app/src/main/java/org/tasks/repeats/CustomRecurrenceActivity.kt index 1547f8d29..f194d968f 100644 --- a/app/src/main/java/org/tasks/repeats/CustomRecurrenceActivity.kt +++ b/app/src/main/java/org/tasks/repeats/CustomRecurrenceActivity.kt @@ -5,8 +5,6 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable import androidx.fragment.app.FragmentActivity import com.google.android.material.composethemeadapter.MdcTheme import dagger.hilt.android.AndroidEntryPoint @@ -43,11 +41,13 @@ class CustomRecurrenceActivity : FragmentActivity() { companion object { const val EXTRA_RRULE = "extra_rrule" const val EXTRA_DATE = "extra_date" + const val EXTRA_ACCOUNT_TYPE = "extra_account_type" @JvmStatic - fun newIntent(context: Context, rrule: String?, dueDate: Long) = + fun newIntent(context: Context, rrule: String?, dueDate: Long, accountType: Int) = Intent(context, CustomRecurrenceActivity::class.java) .putExtra(EXTRA_DATE, dueDate) .putExtra(EXTRA_RRULE, rrule) + .putExtra(EXTRA_ACCOUNT_TYPE, accountType) } } diff --git a/app/src/main/java/org/tasks/repeats/CustomRecurrenceViewModel.kt b/app/src/main/java/org/tasks/repeats/CustomRecurrenceViewModel.kt index 59e9b3664..66a9c63aa 100644 --- a/app/src/main/java/org/tasks/repeats/CustomRecurrenceViewModel.kt +++ b/app/src/main/java/org/tasks/repeats/CustomRecurrenceViewModel.kt @@ -17,7 +17,9 @@ import net.fortuna.ical4j.model.Recur.Frequency.YEARLY import net.fortuna.ical4j.model.WeekDay import net.fortuna.ical4j.model.WeekDayList import net.fortuna.ical4j.model.property.RRule +import org.tasks.data.CaldavAccount.Companion.TYPE_MICROSOFT 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_RRULE import org.tasks.time.DateTime @@ -43,11 +45,12 @@ class CustomRecurrenceViewModel @Inject constructor( val endSelection: Int = 0, val endDate: Long = dueDate.toDateTime().plusMonths(1).startOfDay().millis, val endCount: Int = 1, - val frequencyOptions: List = DEFAULT_FREQUENCIES, + val frequencyOptions: List = FREQ_ALL, val daysOfWeek: List = Locale.getDefault().daysOfWeek(), val selectedDays: List = emptyList(), val locale: Locale = Locale.getDefault(), val monthDay: WeekDay? = null, + val isMicrosoftTask: Boolean = false, ) { val dueDayOfWeek: DayOfWeek get() = Instant.ofEpochMilli(dueDate).atZone(ZoneId.systemDefault()).dayOfWeek @@ -78,17 +81,15 @@ class CustomRecurrenceViewModel @Inject constructor( .get(EXTRA_DATE) ?.takeIf { it > 0 } ?: System.currentTimeMillis().startOfDay() - val selectedDays = if (recur?.frequency == WEEKLY) { - recur.dayList?.toDaysOfWeek() - } else { - emptyList() - } + val isMicrosoftTask = savedStateHandle.get(EXTRA_ACCOUNT_TYPE) == TYPE_MICROSOFT + val frequencies = if (isMicrosoftTask) FREQ_MICROSOFT else FREQ_ALL _state.update { state -> state.copy( interval = recur?.interval?.takeIf { it > 0 } ?: 1, - frequency = recur?.frequency ?: WEEKLY, + frequency = recur?.frequency?.takeIf { frequencies.contains(it) } ?: WEEKLY, dueDate = dueDate, endSelection = when { + isMicrosoftTask -> 0 recur == null -> 0 recur.until != null -> 1 recur.count >= 0 -> 2 @@ -103,7 +104,12 @@ class CustomRecurrenceViewModel @Inject constructor( ?.toDaysOfWeek() ?: emptyList(), 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 { - 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 { val values = DayOfWeek.values() diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt index 2a45f6ef0..5769422bc 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt @@ -1,10 +1,14 @@ package org.tasks.sync.microsoft 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.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.DateTimeUtils.startOfDay import java.text.SimpleDateFormat import java.time.ZonedDateTime import java.util.Locale @@ -13,6 +17,8 @@ import java.util.TimeZone object MicrosoftConverter { 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( remote: Tasks.Task, @@ -30,8 +36,34 @@ object MicrosoftConverter { dueDate = remote.dueDateTime.toLong(0L) creationDate = remote.createdDateTime.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 - // repeat // sync reminders // sync files } @@ -59,24 +91,72 @@ object MicrosoftConverter { categories = tags.map { it.name!! }.takeIf { it.isNotEmpty() }, dueDateTime = if (hasDueDate()) { Tasks.Task.DateTime( - dateTime = DateTime(dueDate.startOfDay()).toUTC().toString("yyyy-MM-dd'T'HH:mm:ss.SSS0000"), - timeZone = "UTC" + 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" ) } else { null }, - lastModifiedDateTime = DateTime(modificationDate).toUTC().toString("yyyy-MM-dd'T'HH:mm:ss.SSS0000'Z'"), - createdDateTime = DateTime(creationDate).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(DATE_TIME_UTC_FORMAT), completedDateTime = if (isCompleted) { Tasks.Task.DateTime( - dateTime = DateTime(completionDate).toUTC() - .toString("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS"), + dateTime = DateTime(completionDate).toUTC().toString(DATE_TIME_FORMAT), timeZone = "UTC", ) } else { 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 = + // reminders + // files ) } diff --git a/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt b/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt index 96c6b74e9..685caf633 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt @@ -21,6 +21,7 @@ data class Tasks( val completedDateTime: DateTime? = null, val dueDateTime: DateTime? = null, val linkedResources: List? = null, + val recurrence: Recurrence? = null, @field:Json(name = "@removed") val removed: Removed? = null, ) { data class Body( @@ -44,6 +45,47 @@ data class Tasks( 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, + 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 { low, normal, diff --git a/app/src/test/java/org/tasks/sync/microsoft/ConvertFromMicrosoftTests.kt b/app/src/test/java/org/tasks/sync/microsoft/ConvertFromMicrosoftTests.kt index 78cbada85..30e297ed0 100644 --- a/app/src/test/java/org/tasks/sync/microsoft/ConvertFromMicrosoftTests.kt +++ b/app/src/test/java/org/tasks/sync/microsoft/ConvertFromMicrosoftTests.kt @@ -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) + } + } } diff --git a/app/src/test/java/org/tasks/sync/microsoft/ConvertToMicrosoftTests.kt b/app/src/test/java/org/tasks/sync/microsoft/ConvertToMicrosoftTests.kt index adea0d055..5f38a0e92 100644 --- a/app/src/test/java/org/tasks/sync/microsoft/ConvertToMicrosoftTests.kt +++ b/app/src/test/java/org/tasks/sync/microsoft/ConvertToMicrosoftTests.kt @@ -4,8 +4,11 @@ import com.natpryce.makeiteasy.MakeItEasy.with import com.todoroo.astrid.data.Task import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test +import org.tasks.Freeze.Companion.freezeAt import org.tasks.TestUtilities.withTZ +import org.tasks.data.CaldavTask import org.tasks.makers.CaldavTaskMaker.REMOTE_ID import org.tasks.makers.CaldavTaskMaker.newCaldavTask 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.DUE_TIME import org.tasks.makers.TaskMaker.PRIORITY +import org.tasks.makers.TaskMaker.RECUR import org.tasks.makers.TaskMaker.TITLE import org.tasks.makers.TaskMaker.newTask import org.tasks.sync.microsoft.MicrosoftConverter.toRemote 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 class ConvertToMicrosoftTests { @Test fun noIdForNewTask() { - val remote = - newTask().toRemote(newCaldavTask(with(REMOTE_ID, null as String?)), emptyList()) + val remote = newTask().toRemote(newCaldavTask(with(REMOTE_ID, null as String?))) assertNull(remote.id) } @Test fun setTitle() { - val remote = - newTask(with(TITLE, "title")).toRemote(newCaldavTask(), emptyList()) + val remote = newTask(with(TITLE, "title")).toRemote(newCaldavTask()) assertEquals("title", remote.title) } @Test fun noBody() { - val remote = - newTask(with(DESCRIPTION, null as String?)) - .toRemote(newCaldavTask(), emptyList()) + val remote = newTask(with(DESCRIPTION, null as String?)).toRemote() assertNull(remote.body) } @Test fun setBody() { - val remote = - newTask(with(DESCRIPTION, "Description")) - .toRemote(newCaldavTask(), emptyList()) + val remote = newTask(with(DESCRIPTION, "Description")).toRemote() assertEquals("Description", remote.body?.content) assertEquals("text", remote.body?.contentType) } @Test fun setHighPriority() { - val remote = - newTask(with(PRIORITY, Task.Priority.HIGH)) - .toRemote(newCaldavTask(), emptyList()) + val remote = newTask(with(PRIORITY, Task.Priority.HIGH)).toRemote() assertEquals(Importance.high, remote.importance) } @Test fun setNormalPriority() { - val remote = - newTask(with(PRIORITY, Task.Priority.MEDIUM)) - .toRemote(newCaldavTask(), emptyList()) + val remote = newTask(with(PRIORITY, Task.Priority.MEDIUM)).toRemote() assertEquals(Importance.normal, remote.importance) } @Test fun setLowPriority() { - val remote = - newTask(with(PRIORITY, Task.Priority.LOW)) - .toRemote(newCaldavTask(), emptyList()) + val remote = newTask(with(PRIORITY, Task.Priority.LOW)).toRemote() assertEquals(Importance.low, remote.importance) } @Test fun setNoPriorityToLow() { - val remote = - newTask(with(PRIORITY, Task.Priority.NONE)) - .toRemote(newCaldavTask(), emptyList()) + val remote = newTask(with(PRIORITY, Task.Priority.NONE)).toRemote() assertEquals(Importance.low, remote.importance) } @Test fun statusForUncompletedTask() { - val remote = newTask().toRemote(newCaldavTask(), emptyList()) + val remote = newTask().toRemote() assertEquals(Tasks.Task.Status.notStarted, remote.status) } @Test fun statusForCompletedTask() { val remote = - newTask(with(COMPLETION_TIME, DateTime())).toRemote(newCaldavTask(), emptyList()) + newTask(with(COMPLETION_TIME, DateTime())).toRemote() assertEquals(Tasks.Task.Status.completed, remote.status) } @Test fun noCategories() { - val remote = newTask().toRemote(newCaldavTask(), emptyList()) + val remote = newTask().toRemote() assertNull(remote.categories) } @@ -120,7 +112,7 @@ class ConvertToMicrosoftTests { withTZ("America/Chicago") { val remote = Task( creationDate = DateTime(2023, 7, 21, 0, 42, 13, 475).millis, - ).toRemote(newCaldavTask(), emptyList()) + ).toRemote() assertEquals( "2023-07-21T05:42:13.4750000Z", remote.createdDateTime @@ -133,7 +125,7 @@ class ConvertToMicrosoftTests { withTZ("America/Chicago") { val remote = Task( modificationDate = DateTime(2023, 7, 21, 0, 49, 4, 3).millis, - ).toRemote(newCaldavTask(), emptyList()) + ).toRemote() assertEquals( "2023-07-21T05:49:04.0030000Z", remote.lastModifiedDateTime @@ -145,14 +137,103 @@ class ConvertToMicrosoftTests { fun setDueDateTime() { withTZ("America/Chicago") { val remote = newTask( - with( - DUE_TIME, - DateTime(2023, 7, 21, 13, 30) - ) + with(DUE_TIME, DateTime(2023, 7, 21, 13, 30)) ) - .toRemote(newCaldavTask(), emptyList()) + .toRemote() assertEquals("2023-07-21T05:00:00.0000000", remote.dueDateTime?.dateTime) assertEquals("UTC", remote.dueDateTime?.timeZone) } } -} \ No newline at end of file + + @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()) +} diff --git a/app/src/test/resources/microsoft/repeat_daily.txt b/app/src/test/resources/microsoft/repeat_daily.txt new file mode 100644 index 000000000..da53200f5 --- /dev/null +++ b/app/src/test/resources/microsoft/repeat_daily.txt @@ -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 + } + } + } + ] +} \ No newline at end of file diff --git a/app/src/test/resources/microsoft/repeat_monthly.txt b/app/src/test/resources/microsoft/repeat_monthly.txt new file mode 100644 index 000000000..c9417a0d0 --- /dev/null +++ b/app/src/test/resources/microsoft/repeat_monthly.txt @@ -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 + } + } + } + ] +} \ No newline at end of file diff --git a/app/src/test/resources/microsoft/repeat_weekdays.txt b/app/src/test/resources/microsoft/repeat_weekdays.txt new file mode 100644 index 000000000..cb41615dd --- /dev/null +++ b/app/src/test/resources/microsoft/repeat_weekdays.txt @@ -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 + } + } + } + ] +} \ No newline at end of file diff --git a/app/src/test/resources/microsoft/repeat_yearly.txt b/app/src/test/resources/microsoft/repeat_yearly.txt new file mode 100644 index 000000000..fed9a3cd3 --- /dev/null +++ b/app/src/test/resources/microsoft/repeat_yearly.txt @@ -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 + } + } + } + ] +} \ No newline at end of file