Move dated sub-tasks with their routine, mark tick copies (#160)
This commit is contained in:
1 parent
6113e73333
commit
6f4ae7e2ec
10 files changed
+515
-102
No files matched your search
+4
-3
@@ -10,9 +10,10 @@ All notable changes to this project are documented here. The format follows
|
||||
### Fixed
|
||||
- Sub-tasks of a repeating task start over when ticking off its current
|
||||
occurrence moves it on to its next date, as in tasks.org: completed and
|
||||
half-done ones open again. Before, the next occurrence showed them already
|
||||
ticked. Unticking gives them back their state, and repeating sub-tasks keep
|
||||
their own schedule (#160).
|
||||
half-done ones open again, and dated ones move along with the task. Before,
|
||||
the next occurrence showed them already ticked. Unticking gives them back
|
||||
their state and dates, and repeating sub-tasks keep their own schedule
|
||||
(#160).
|
||||
|
||||
## [1.3.1] - 2026-11-09
|
||||
|
||||
|
||||
+38
@@ -14,6 +14,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -763,6 +764,43 @@ class RoomTasksDataSourceTest {
|
||||
assertThat(db.tasks().entity(touched)!!.percentComplete).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDatedSubtaskMovesWithItsRoutineAndBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
|
||||
val undated = source.insertTask(form(title = "Undated").copy(parentId = id))
|
||||
source.setCompleted(step, completed = true)
|
||||
val due = db.tasks().entity(step)!!.due!!
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
// A week of wall-clock time: an hour either way across a DST change.
|
||||
assertThat(db.tasks().entity(step)!!.due!! - due).isIn(com.google.common.collect.Range.closed(7.days - 1.hours, 7.days + 1.hours))
|
||||
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(undated)!!.due).isNull()
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(step)!!.due).isEqualTo(due)
|
||||
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMarkedCopyUnderARoutineIsLeftAlone() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val copy = source.insertTask(form(title = "Daily part").copy(parentId = id))
|
||||
source.setCompleted(copy, completed = true)
|
||||
db.tasks().update(db.tasks().entity(copy)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:1"))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(copy)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRoutineTickedOnItsLastOccurrenceLeavesItsSubtasksDone() {
|
||||
val at = now
|
||||
|
||||
@@ -31,6 +31,7 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Instant
|
||||
@@ -196,12 +197,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
filterArgs: List<String> = emptyList(),
|
||||
sortOrder: String? = null,
|
||||
read: (ColumnReader) -> T?,
|
||||
): List<T> = ids.distinct().chunked(MAX_SQL_ARGS - filterArgs.size).flatMap { chunk ->
|
||||
val selection = buildString {
|
||||
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
|
||||
if (filter != null) append(" AND ($filter)")
|
||||
}
|
||||
val args = (chunk.map(Long::toString) + filterArgs).toTypedArray()
|
||||
): List<T> = inSelections(idColumn, ids, filter, filterArgs).flatMap { (selection, args) ->
|
||||
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
|
||||
@@ -743,7 +739,8 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
* into a task of its own, remembers that copy, so the occurrence can still be
|
||||
* read and reopened, and how the series moved, so reopening can move it back.
|
||||
* When the series moved on, its sub-tasks start over for the next occurrence,
|
||||
* and the tick remembers how they were (#160).
|
||||
* and the tick remembers how they were (#160). Without a copy there is no
|
||||
* tick to remember them in, so they are left as they are.
|
||||
*/
|
||||
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
|
||||
if (!closing) return write()
|
||||
@@ -757,13 +754,18 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
.onFailure { Log.w(TAG, "Reading the series after the tick failed", it) }
|
||||
.getOrNull()?.takeIf { it != before }
|
||||
}
|
||||
val reset = if (after != null && SubtaskReset.movedOn(before, after, runCatching { seriesSpec(taskId) }.getOrNull())) {
|
||||
runCatching { resetSubtasks(authority, taskId) }.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
runCatching {
|
||||
detachedSince(authority, lastId)?.let { copy ->
|
||||
val copy = detachedSince(authority, lastId)
|
||||
val resets = SubtaskReset.resetsOnTick(copy, before, after)
|
||||
copy?.let {
|
||||
markCopy(authority, copy)
|
||||
val reset = if (resets && before != null && after != null) {
|
||||
runCatching { resetSubtasks(authority, taskId, SubtaskReset.shiftOf(before, after)) }
|
||||
.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }
|
||||
.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tick = Tick(
|
||||
copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis(),
|
||||
reset = reset?.let(SubtaskReset::encode),
|
||||
@@ -775,51 +777,83 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets what [SubtaskReset] picks under [parentId]. */
|
||||
private fun resetSubtasks(authority: String, parentId: Long): List<ResetSubtask> {
|
||||
val copies = ticks.copies(authority)
|
||||
val reset = SubtaskReset.toReset(parentId) { id ->
|
||||
/** Marks [copyId] as a tick's copy where DAVx5 keeps and uploads unknown properties, so other installs know it too. */
|
||||
private fun markCopy(authority: String, copyId: Long) {
|
||||
val values = mapOf(
|
||||
Properties.TASK_ID to copyId,
|
||||
Properties.MIMETYPE to SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE,
|
||||
Properties.DATA0 to SubtaskReset.COPY_MARKER_JSON,
|
||||
)
|
||||
runCatching { resolver.insert(TasksContract.propertiesUri(authority), values.toContentValues()) }
|
||||
.onFailure { Log.w(TAG, "Marking the ticked copy failed", it) }
|
||||
}
|
||||
|
||||
/** The rows carrying Agendula's copy marker. */
|
||||
private fun markedCopies(authority: String): Set<Long> = runCatching {
|
||||
resolver.query(
|
||||
TasksContract.propertiesUri(authority),
|
||||
arrayOf(Properties.TASK_ID, Properties.DATA0),
|
||||
"${Properties.MIMETYPE} = ? AND ${Properties.DATA0} LIKE ?",
|
||||
arrayOf(SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE, "%${SubtaskReset.COPY_MARKER}%"),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildSet { while (c.moveToNext()) if (SubtaskReset.isCopyMarker(r.getString(Properties.DATA0))) r.getLong(Properties.TASK_ID)?.let(::add) }
|
||||
}.orEmpty()
|
||||
}.onFailure { Log.w(TAG, "Reading the copy markers failed", it) }.getOrDefault(emptySet())
|
||||
|
||||
/** Resets what [SubtaskReset] picks under [parentId], dated ones moved by [shift]. */
|
||||
private fun resetSubtasks(authority: String, parentId: Long, shift: SeriesShift?): List<ResetSubtask> {
|
||||
val copies = ticks.copies(authority) + markedCopies(authority)
|
||||
val reset = SubtaskReset.toReset(parentId, shift) { id ->
|
||||
childRows(authority, id, SUBTASK_STATE) { r ->
|
||||
val rowId = r.getLong(Tasks.ID) ?: return@childRows null
|
||||
SubtaskReset.providerNode(rowId, r.getString(Tasks.RRULE), r.getString(Tasks.RDATE), copies, subtaskState(r))
|
||||
val allDay = r.getBoolean(Tasks.IS_ALLDAY)
|
||||
SubtaskReset.providerNode(
|
||||
rowId, r.getString(Tasks.RRULE), r.getString(Tasks.RDATE), copies, subtaskState(r),
|
||||
SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE)), allDay, zoneOf(allDay, r.getString(Tasks.TZ)),
|
||||
)
|
||||
}
|
||||
}
|
||||
writeSubtaskStates(authority, reset.map { it.id to SubtaskState.FRESH })
|
||||
val ops = reset.map { subtaskOp(authority, it.id, SubtaskState.FRESH, it.dates.takeIf { _ -> it.dates != it.priorDates }) }
|
||||
if (ops.isNotEmpty()) resolver.applyBatch(authority, ArrayList(ops))
|
||||
return reset
|
||||
}
|
||||
|
||||
private fun zoneOf(allDay: Boolean, tz: String?): ZoneId =
|
||||
if (allDay) ZoneOffset.UTC else tz?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: ZoneId.systemDefault()
|
||||
|
||||
private fun subtaskState(r: ColumnReader) =
|
||||
SubtaskState(statusFromInt(r.getInt(Tasks.STATUS)), r.getInt(Tasks.PERCENT_COMPLETE), r.getLong(Tasks.COMPLETED))
|
||||
|
||||
private fun subtaskOps(authority: String, states: List<Pair<Long, SubtaskState>>): List<ContentProviderOperation> =
|
||||
states.map { (id, state) ->
|
||||
val values = mapOf(
|
||||
Tasks.STATUS to state.status.toInt(),
|
||||
Tasks.PERCENT_COMPLETE to state.percent,
|
||||
Tasks.COMPLETED to state.completedAt,
|
||||
)
|
||||
ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build()
|
||||
/** [state] onto sub-task [id], and [dates] when they change. */
|
||||
private fun subtaskOp(authority: String, id: Long, state: SubtaskState, dates: SubtaskDates?): ContentProviderOperation {
|
||||
val values = buildMap<String, Any?> {
|
||||
put(Tasks.STATUS, state.status.toInt())
|
||||
put(Tasks.PERCENT_COMPLETE, state.percent)
|
||||
put(Tasks.COMPLETED, state.completedAt)
|
||||
if (dates != null) {
|
||||
put(Tasks.DTSTART, dates.start)
|
||||
put(Tasks.DUE, dates.due)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeSubtaskStates(authority: String, states: List<Pair<Long, SubtaskState>>) {
|
||||
if (states.isNotEmpty()) resolver.applyBatch(authority, ArrayList(subtaskOps(authority, states)))
|
||||
return ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build()
|
||||
}
|
||||
|
||||
/** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state. */
|
||||
/** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state and dates. */
|
||||
private fun subtaskRestore(authority: String, reset: String?): List<ContentProviderOperation> {
|
||||
val entries = SubtaskReset.decode(reset)
|
||||
if (entries.isEmpty()) return emptyList()
|
||||
val current = resolver.query(
|
||||
val current = queryByIds(
|
||||
TasksContract.tasksUri(authority),
|
||||
SUBTASK_STATE,
|
||||
"${Tasks.ID} IN (${entries.joinToString { "?" }}) AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||
entries.map { it.id.toString() }.toTypedArray(),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildMap { while (c.moveToNext()) r.getLong(Tasks.ID)?.let { put(it, subtaskState(r)) } }
|
||||
}.orEmpty()
|
||||
return subtaskOps(authority, SubtaskReset.toRestore(entries, current::get).map { it.id to it.prior })
|
||||
Tasks.ID,
|
||||
entries.map { it.id },
|
||||
filter = "${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0",
|
||||
) { r -> r.getLong(Tasks.ID)?.let { it to (subtaskState(r) to SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE))) } }.toMap()
|
||||
return SubtaskReset.toRestore(entries, current::get).map {
|
||||
subtaskOp(authority, it.id, it.prior, it.priorDates.takeIf { _ -> it.priorDates != it.dates })
|
||||
}
|
||||
}
|
||||
|
||||
/** Walks [parentId]'s direct sub-task rows, overrides left out, with [read] on each. */
|
||||
@@ -854,8 +888,11 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
?: return false
|
||||
val series = tick.seriesId ?: return false
|
||||
val before = tick.before ?: return false
|
||||
if (seriesState(authority, series) != tick.after || subtaskIds(tick.copyId).isNotEmpty()) return false
|
||||
val restore = before.filter { (column, value) -> tick.after?.get(column) != value }
|
||||
val after = tick.after ?: return false
|
||||
// Compared on what the tick recorded: one from before IS_ALLDAY and TZ were read has neither.
|
||||
val now = seriesState(authority, series) ?: return false
|
||||
if (after.any { (column, value) -> now[column] != value } || subtaskIds(tick.copyId).isNotEmpty()) return false
|
||||
val restore = before.filter { (column, value) -> after[column] != value }
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
|
||||
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
|
||||
@@ -867,9 +904,8 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false)
|
||||
|
||||
/** The columns a tick moves a series by, or null when [taskId] is no series. */
|
||||
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? {
|
||||
if (seriesRules(listOf(taskId)).isEmpty()) return null
|
||||
return resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
|
||||
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? =
|
||||
resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
SERIES_STATE.indices.associate { i ->
|
||||
SERIES_STATE[i] to when (c.getType(i)) {
|
||||
@@ -878,8 +914,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
else -> c.getString(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.takeIf { state -> listOf(Tasks.RRULE, Tasks.RDATE).any { !(state[it] as? String).isNullOrBlank() } }
|
||||
|
||||
private fun lastTaskId(authority: String): Long =
|
||||
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
|
||||
@@ -1011,18 +1046,19 @@ private const val MIGRATIONS = "provider_migrations"
|
||||
|
||||
private const val TICKED_COPIES = "provider_ticked_copies"
|
||||
|
||||
/** Every copy a tick left, kept past [TickedCopies.HORIZON_MS]: nothing on the row itself marks it. */
|
||||
/** Every copy a tick on this install left, kept past [TickedCopies.HORIZON_MS] alongside the marker on the row. */
|
||||
private const val KNOWN_COPIES = "provider_known_copies"
|
||||
|
||||
/** What a tick changes on a series, and an undo changes back. */
|
||||
/** What resetting a sub-task reads: whether it recurs, and its progress. */
|
||||
/** What resetting a sub-task reads: whether it recurs, its progress and its dates. */
|
||||
private val SUBTASK_STATE = arrayOf(
|
||||
Tasks.ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.RRULE, Tasks.RDATE, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED,
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ,
|
||||
)
|
||||
|
||||
/** What a tick changes on a series, and an undo changes back; with what its rule is read in. */
|
||||
private val SERIES_STATE = arrayOf(
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
|
||||
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, Tasks.TZ,
|
||||
)
|
||||
|
||||
private const val TAG = "AndroidTasksDataSource"
|
||||
@@ -1030,5 +1066,3 @@ private const val TAG = "AndroidTasksDataSource"
|
||||
/** No rule to look up: the provider says the row does not recur. */
|
||||
private const val NO_RULE = -1L
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
private const val MAX_SQL_ARGS = 999
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
internal const val MAX_SQL_ARGS = 999
|
||||
|
||||
/**
|
||||
* `idColumn IN (…)` selections over [ids], each with its arguments and
|
||||
* [filterArgs] after them, in chunks that keep every query within [limit]
|
||||
* bound arguments.
|
||||
*/
|
||||
internal fun inSelections(
|
||||
idColumn: String,
|
||||
ids: Collection<Long>,
|
||||
filter: String? = null,
|
||||
filterArgs: List<String> = emptyList(),
|
||||
limit: Int = MAX_SQL_ARGS,
|
||||
): List<Pair<String, Array<String>>> = ids.distinct().chunked(limit - filterArgs.size).map { chunk ->
|
||||
val selection = buildString {
|
||||
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
|
||||
if (filter != null) append(" AND ($filter)")
|
||||
}
|
||||
selection to (chunk.map(Long::toString) + filterArgs).toTypedArray()
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -20,33 +24,102 @@ internal data class SubtaskState(val status: TaskStatus, val percent: Int?, val
|
||||
}
|
||||
}
|
||||
|
||||
/** A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of a recurring one left. */
|
||||
internal class SubtaskNode(val id: Long, val recurring: Boolean, val state: SubtaskState)
|
||||
/** A sub-task's own start and due, as epoch millis. */
|
||||
internal data class SubtaskDates(val start: Long?, val due: Long?) {
|
||||
val isEmpty: Boolean get() = start == null && due == null
|
||||
|
||||
/** A sub-task [SubtaskReset] reset, with the state it had. */
|
||||
internal data class ResetSubtask(val id: Long, val prior: SubtaskState)
|
||||
companion object {
|
||||
val NONE = SubtaskDates(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of
|
||||
* a recurring one left. [zone] is the one its dates are read in.
|
||||
*/
|
||||
internal class SubtaskNode(
|
||||
val id: Long,
|
||||
val recurring: Boolean,
|
||||
val state: SubtaskState,
|
||||
val dates: SubtaskDates = SubtaskDates.NONE,
|
||||
val isAllDay: Boolean = false,
|
||||
val zone: ZoneId = ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
/** A sub-task [SubtaskReset] reset: its state and dates before, and the dates the reset gave it. */
|
||||
internal data class ResetSubtask(
|
||||
val id: Long,
|
||||
val prior: SubtaskState,
|
||||
val priorDates: SubtaskDates = SubtaskDates.NONE,
|
||||
val dates: SubtaskDates = priorDates,
|
||||
)
|
||||
|
||||
/**
|
||||
* How far a series moved, as wall-clock time in [seriesZone], so a sub-task
|
||||
* keeps its place relative to the routine across a DST change.
|
||||
*/
|
||||
internal class SeriesShift(from: Long, to: Long, seriesZone: ZoneId) {
|
||||
private val delta = Duration.between(local(from, seriesZone), local(to, seriesZone))
|
||||
|
||||
/** [at] moved along; an all-day date by whole days. */
|
||||
fun apply(at: Long, isAllDay: Boolean, zone: ZoneId): Long {
|
||||
val z = if (isAllDay) ZoneOffset.UTC else zone
|
||||
val by = if (isAllDay) Duration.ofDays(delta.toDays()) else delta
|
||||
return local(at, z).plus(by).atZone(z).toInstant().toEpochMilli()
|
||||
}
|
||||
|
||||
private fun local(at: Long, zone: ZoneId) = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(at), zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a routine's sub-tasks do when its series moves on to the next occurrence
|
||||
* (#160): the ones completed or in progress start over, as in tasks.org, and
|
||||
* moving the series back gives them their state again.
|
||||
* (#160): the ones completed or in progress start over, as in tasks.org, dated
|
||||
* ones move along with the routine, and moving the series back gives them their
|
||||
* state and dates again.
|
||||
*/
|
||||
internal object SubtaskReset {
|
||||
|
||||
/** Only bounds the search for a next occurrence. */
|
||||
private val LOOKAHEAD = (366 * 1000).days
|
||||
|
||||
/** The `X-` property Agendula puts on the copy a provider tick left, so any device knows it for one. */
|
||||
const val COPY_MARKER = "X-AGENDULA-TICK-COPY"
|
||||
|
||||
/** The marker as DAVx5 stores an unknown property: a JSON array of name and value. */
|
||||
const val COPY_MARKER_JSON = "[\"$COPY_MARKER\",\"1\"]"
|
||||
|
||||
/** The provider `properties` mimetype DAVx5 keeps unknown properties under, and round-trips. */
|
||||
const val UNKNOWN_PROPERTY_MIMETYPE = "vnd.android.cursor.item/vnd.ical4android.unknown-property"
|
||||
|
||||
/** Whether an unknown-property row's JSON is the copy marker. */
|
||||
fun isCopyMarker(json: String?): Boolean =
|
||||
json?.trim()?.startsWith("[\"$COPY_MARKER\"", ignoreCase = true) == true
|
||||
|
||||
/** Whether a stored residue carries the copy marker, as a copy synced in from a provider install does. */
|
||||
fun hasCopyMarker(residue: String?): Boolean = residue?.lines()?.any {
|
||||
val line = it.trim()
|
||||
line.startsWith(COPY_MARKER, ignoreCase = true) && line.getOrNull(COPY_MARKER.length).let { c -> c == ':' || c == ';' }
|
||||
} == true
|
||||
|
||||
/**
|
||||
* The sub-tasks under [parentId], all the way down, to reset. A recurring one
|
||||
* has a series of its own and is left alone with everything under it; a
|
||||
* cancelled one stays cancelled.
|
||||
* The sub-tasks under [parentId], all the way down, to reset: started ones,
|
||||
* and with a [shift], dated ones too. A recurring one has a series of its
|
||||
* own and is left alone with everything under it; a cancelled one stays as
|
||||
* it is.
|
||||
*/
|
||||
fun toReset(parentId: Long, children: (Long) -> List<SubtaskNode>): List<ResetSubtask> {
|
||||
fun toReset(parentId: Long, shift: SeriesShift? = null, children: (Long) -> List<SubtaskNode>): List<ResetSubtask> {
|
||||
val seen = mutableSetOf(parentId)
|
||||
val reset = mutableListOf<ResetSubtask>()
|
||||
fun walk(id: Long) {
|
||||
children(id).filter { !it.recurring && seen.add(it.id) }.forEach {
|
||||
if (it.state.status != TaskStatus.CANCELLED && !it.state.isFresh) reset += ResetSubtask(it.id, it.state)
|
||||
val moves = shift != null && !it.dates.isEmpty
|
||||
if (it.state.status != TaskStatus.CANCELLED && (!it.state.isFresh || moves)) {
|
||||
val dates = if (shift == null) it.dates else SubtaskDates(
|
||||
it.dates.start?.let { at -> shift.apply(at, it.isAllDay, it.zone) },
|
||||
it.dates.due?.let { at -> shift.apply(at, it.isAllDay, it.zone) },
|
||||
)
|
||||
reset += ResetSubtask(it.id, it.state, it.dates, dates)
|
||||
}
|
||||
walk(it.id)
|
||||
}
|
||||
}
|
||||
@@ -55,27 +128,67 @@ internal object SubtaskReset {
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider row as a node. Nothing on a copy the provider detached marks it
|
||||
* as one, so only the [copies] a tick in Agendula left are known: they belong
|
||||
* to a recurring sub-task and count as recurring.
|
||||
* A provider row as a node. Nothing the provider sets on a detached copy marks
|
||||
* it as one, so a copy is known by Agendula's marker or by the [copies] ticks
|
||||
* on this install left: it belongs to a recurring sub-task and counts as recurring.
|
||||
*/
|
||||
fun providerNode(id: Long, rrule: String?, rdate: String?, copies: Set<Long>, state: SubtaskState) =
|
||||
SubtaskNode(id, !rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies, state)
|
||||
fun providerNode(
|
||||
id: Long,
|
||||
rrule: String?,
|
||||
rdate: String?,
|
||||
copies: Set<Long>,
|
||||
state: SubtaskState,
|
||||
dates: SubtaskDates = SubtaskDates.NONE,
|
||||
isAllDay: Boolean = false,
|
||||
zone: ZoneId = ZoneOffset.UTC,
|
||||
) = SubtaskNode(id, !rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies, state, dates, isAllDay, zone)
|
||||
|
||||
/** The reset sub-tasks still as the reset left them, so moving back may restore them. */
|
||||
fun toRestore(reset: List<ResetSubtask>, current: (Long) -> SubtaskState?): List<ResetSubtask> =
|
||||
reset.filter { current(it.id)?.isFresh == true }
|
||||
fun toRestore(reset: List<ResetSubtask>, current: (Long) -> Pair<SubtaskState, SubtaskDates>?): List<ResetSubtask> =
|
||||
reset.filter { entry -> current(entry.id)?.let { (state, dates) -> state.isFresh && dates == entry.dates } == true }
|
||||
|
||||
fun encode(reset: List<ResetSubtask>): String = reset.joinToString(";") {
|
||||
listOf(it.id, it.prior.status.name, it.prior.percent ?: "", it.prior.completedAt ?: "").joinToString(",")
|
||||
listOf(
|
||||
it.id, it.prior.status.name, it.prior.percent, it.prior.completedAt,
|
||||
it.priorDates.start, it.priorDates.due, it.dates.start, it.dates.due,
|
||||
).joinToString(",") { value -> value?.toString().orEmpty() }
|
||||
}
|
||||
|
||||
fun decode(raw: String?): List<ResetSubtask> = raw.orEmpty().split(';').mapNotNull { entry ->
|
||||
val parts = entry.split(',')
|
||||
if (parts.size != 4) return@mapNotNull null
|
||||
if (parts.size != 4 && parts.size != 8) return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
val status = TaskStatus.entries.firstOrNull { it.name == parts[1] } ?: return@mapNotNull null
|
||||
ResetSubtask(id, SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull()))
|
||||
val prior = SubtaskDates(parts.getOrNull(4)?.toLongOrNull(), parts.getOrNull(5)?.toLongOrNull())
|
||||
val dates = if (parts.size == 8) SubtaskDates(parts[6].toLongOrNull(), parts[7].toLongOrNull()) else prior
|
||||
ResetSubtask(id, SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull()), prior, dates)
|
||||
}
|
||||
|
||||
/** A provider series' rule set from its columns, or null when it does not recur. */
|
||||
fun specOf(state: Map<String, Any?>): RecurrenceSpec? {
|
||||
val rrule = (state[Tasks.RRULE] as? String)?.ifBlank { null }
|
||||
val rdate = (state[Tasks.RDATE] as? String)?.ifBlank { null }
|
||||
if (rrule == null && rdate == null) return null
|
||||
val anchor = anchor(state) ?: return null
|
||||
return RecurrenceSpec(
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = state[Tasks.EXDATE] as? String,
|
||||
anchor = Instant.fromEpochMilliseconds(anchor),
|
||||
isAllDay = isAllDay(state),
|
||||
timeZone = state[Tasks.TZ] as? String,
|
||||
)
|
||||
}
|
||||
|
||||
fun isAllDay(state: Map<String, Any?>): Boolean = (state[Tasks.IS_ALLDAY] as? Number)?.toInt()?.let { it != 0 } == true
|
||||
|
||||
/** How the series moved from [before] to [after], read in the zone its rule is expanded in. */
|
||||
fun shiftOf(before: Map<String, Any?>, after: Map<String, Any?>, floating: ZoneId = ZoneId.systemDefault()): SeriesShift? {
|
||||
val from = anchor(before) ?: return null
|
||||
val to = anchor(after) ?: return null
|
||||
val zone = if (isAllDay(after)) ZoneOffset.UTC
|
||||
else (after[Tasks.TZ] as? String)?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: floating
|
||||
return SeriesShift(from, to, zone)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +198,7 @@ internal object SubtaskReset {
|
||||
* there. An `EXDATE` or a status alone is no move; a series whose rule ran
|
||||
* out is finished.
|
||||
*/
|
||||
fun movedOn(before: Map<String, Any?>?, after: Map<String, Any?>?, next: RecurrenceSpec?): Boolean {
|
||||
fun movedOn(before: Map<String, Any?>?, after: Map<String, Any?>?, next: RecurrenceSpec? = after?.let(::specOf)): Boolean {
|
||||
val was = anchor(before ?: return false) ?: return false
|
||||
val now = anchor(after ?: return false) ?: return false
|
||||
if (now <= was) return false
|
||||
@@ -104,6 +217,14 @@ internal object SubtaskReset {
|
||||
return RecurrenceExpander.expand(probe, ExpansionWindow(from = from, until = from + LOOKAHEAD, maxOccurrences = 1)).isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider tick resets the sub-tasks: the series moved on, and the
|
||||
* provider left a [copy] whose tick can record what the reset changed. With
|
||||
* no copy there is nothing to undo through, so they are left as they are.
|
||||
*/
|
||||
fun resetsOnTick(copy: Long?, before: Map<String, Any?>?, after: Map<String, Any?>?): Boolean =
|
||||
copy != null && movedOn(before, after)
|
||||
|
||||
private fun anchor(state: Map<String, Any?>): Long? =
|
||||
(state[Tasks.DTSTART] as? Number)?.toLong() ?: (state[Tasks.DUE] as? Number)?.toLong()
|
||||
}
|
||||
@@ -39,31 +39,38 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private
|
||||
|
||||
/** Every copy a tick in Agendula left in [authority]. */
|
||||
fun copies(authority: String): Set<Long> {
|
||||
val kept = known().getStringSet(authority, emptySet()).orEmpty().mapNotNullTo(HashSet()) { it.toLongOrNull() }
|
||||
val kept = knownIds(authority).mapNotNullTo(HashSet()) { it.toLongOrNull() }
|
||||
prefs().all.forEach { (key, value) ->
|
||||
if (key.startsWith("$authority|")) (value as? String)?.let(::decode)?.let { kept += it.copyId }
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/** Records [tick] under [key]; ticks past [HORIZON_MS] go, their copies stay known. */
|
||||
fun put(key: String, tick: Tick) {
|
||||
val stale = prefs().all.filter { (_, value) ->
|
||||
val at = (value as? String)?.let(::decode)?.at ?: 0L
|
||||
tick.at - at > HORIZON_MS
|
||||
}.keys
|
||||
val (stale, keep) = expire(prefs().all.mapValues { (_, value) -> (value as? String)?.let(::decode) }, tick.at)
|
||||
val known = keep + (key.substringBefore('|') to (keep[key.substringBefore('|')].orEmpty() + tick.copyId))
|
||||
known().edit().apply {
|
||||
known.forEach { (authority, ids) -> putStringSet(authority, knownIds(authority) + ids.map(Long::toString)) }
|
||||
}.apply()
|
||||
prefs().edit().apply {
|
||||
stale.forEach(::remove)
|
||||
putString(key, encode(tick))
|
||||
}.apply()
|
||||
val authority = key.substringBefore('|')
|
||||
val kept = known().getStringSet(authority, emptySet()).orEmpty()
|
||||
known().edit().putStringSet(authority, kept + tick.copyId.toString()).apply()
|
||||
}
|
||||
|
||||
/** Drops the tick under [key], and its copy from the known ones: moving back deleted it, and its id may be reused. */
|
||||
fun remove(key: String) {
|
||||
val copy = get(key)?.copyId
|
||||
prefs().edit().remove(key).apply()
|
||||
if (copy != null) {
|
||||
val authority = key.substringBefore('|')
|
||||
known().edit().putStringSet(authority, knownIds(authority) - copy.toString()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun knownIds(authority: String): Set<String> = known().getStringSet(authority, emptySet()).orEmpty().toSet()
|
||||
|
||||
private fun encode(tick: Tick): String = JSONObject().apply {
|
||||
put("copy", tick.copyId)
|
||||
put("at", tick.at)
|
||||
@@ -100,5 +107,16 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private
|
||||
companion object {
|
||||
/** As long as the own store keeps a tick undoable. */
|
||||
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* The keys of [ticks] past the horizon at [now], unreadable ones included,
|
||||
* and per authority the copies they leave, which stay known after the tick goes.
|
||||
*/
|
||||
fun expire(ticks: Map<String, Tick?>, now: Long): Pair<Set<String>, Map<String, Set<Long>>> {
|
||||
val stale = ticks.filter { (_, tick) -> now - (tick?.at ?: 0L) > HORIZON_MS }
|
||||
val keep = stale.entries.mapNotNull { (key, tick) -> tick?.let { key.substringBefore('|') to it.copyId } }
|
||||
.groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() }
|
||||
return stale.keys to keep
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
-12
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ResetSubtask
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SeriesShift
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskDates
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskReset
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskState
|
||||
@@ -22,6 +24,7 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -642,7 +645,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
isDirty = true,
|
||||
),
|
||||
)
|
||||
val reset = resetSubtasks(master.id, now)
|
||||
val (reset, resetLists) = resetSubtasks(master, newAnchor, now)
|
||||
val copyId = if (override != null) {
|
||||
detach(override, now, close)
|
||||
} else {
|
||||
@@ -672,33 +675,62 @@ class RoomTasksDataSource @Inject constructor(
|
||||
),
|
||||
)
|
||||
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
|
||||
writes.onWritten(setOf(master.listId) + reset.mapNotNull { tasks.entity(it.id)?.listId })
|
||||
writes.onWritten(setOf(master.listId) + resetLists)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Resets what [SubtaskReset] picks under [parentId] for the series' next occurrence. */
|
||||
private fun resetSubtasks(parentId: Long, now: Instant): List<ResetSubtask> {
|
||||
val reset = SubtaskReset.toReset(parentId) { id ->
|
||||
tasks.subtasks(id).map { SubtaskNode(it.task.id, it.task.isRecurring, it.task.progress) }
|
||||
/**
|
||||
* Resets what [SubtaskReset] picks under [master] for the series' next
|
||||
* occurrence at [newAnchor], dated ones moved along; the lists it wrote to.
|
||||
*/
|
||||
private fun resetSubtasks(master: TaskEntity, newAnchor: Instant, now: Instant): Pair<List<ResetSubtask>, Set<Long>> {
|
||||
val shift = master.recurrenceAnchor?.let {
|
||||
SeriesShift(it.toEpochMilliseconds(), newAnchor.toEpochMilliseconds(), master.zoneId())
|
||||
}
|
||||
reset.forEach { tasks.entity(it.id)?.let { sub -> tasks.update(sub.withProgress(SubtaskState.FRESH, now)) } }
|
||||
return reset
|
||||
val loaded = HashMap<Long, TaskEntity>()
|
||||
val reset = SubtaskReset.toReset(master.id, shift) { id ->
|
||||
tasks.subtasks(id).map { row ->
|
||||
val sub = row.task.also { loaded[it.id] = it }
|
||||
SubtaskNode(
|
||||
sub.id,
|
||||
sub.isRecurring || SubtaskReset.hasCopyMarker(sub.unknownProperties),
|
||||
sub.progress,
|
||||
sub.dates,
|
||||
sub.isAllDay,
|
||||
sub.zoneId(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val lists = reset.mapNotNull { entry ->
|
||||
loaded[entry.id]?.also { tasks.update(it.withProgress(SubtaskState.FRESH, entry.dates, now)) }?.listId
|
||||
}
|
||||
return reset to lists.toSet()
|
||||
}
|
||||
|
||||
/** What [resetSubtasks] reset for [record], back as it was where nothing changed it since. */
|
||||
private fun restoreSubtasks(record: AdvancedOccurrenceEntity, now: Instant): Set<Long> {
|
||||
val restore = SubtaskReset.toRestore(SubtaskReset.decode(record.resetSubtasks)) { tasks.entity(it)?.takeUnless { e -> e.isDeleted }?.progress }
|
||||
return restore.mapNotNull { reset ->
|
||||
tasks.entity(reset.id)?.also { tasks.update(it.withProgress(reset.prior, now)) }?.listId
|
||||
val entries = SubtaskReset.decode(record.resetSubtasks)
|
||||
val current = entries.mapNotNull { entry -> tasks.entity(entry.id)?.takeUnless { it.isDeleted } }.associateBy { it.id }
|
||||
val restore = SubtaskReset.toRestore(entries) { id -> current[id]?.let { it.progress to it.dates } }
|
||||
return restore.mapNotNull { entry ->
|
||||
current[entry.id]?.also { tasks.update(it.withProgress(entry.prior, entry.priorDates, now)) }?.listId
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
private val TaskEntity.progress get() = SubtaskState(status, percentComplete, completedAt?.toEpochMilliseconds())
|
||||
|
||||
private fun TaskEntity.withProgress(state: SubtaskState, now: Instant) = copy(
|
||||
private val TaskEntity.dates get() = SubtaskDates(dtstart?.toEpochMilliseconds(), due?.toEpochMilliseconds())
|
||||
|
||||
/** The zone a row's dates are read in, as [RecurrenceExpander] reads a series. */
|
||||
private fun TaskEntity.zoneId(): ZoneId =
|
||||
if (isAllDay) ZoneOffset.UTC else timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: ZoneId.systemDefault()
|
||||
|
||||
private fun TaskEntity.withProgress(state: SubtaskState, dates: SubtaskDates, now: Instant) = copy(
|
||||
status = state.status,
|
||||
percentComplete = state.percent,
|
||||
completedAt = state.completedAt?.let(Instant::fromEpochMilliseconds),
|
||||
dtstart = dates.start?.let(Instant::fromEpochMilliseconds),
|
||||
due = dates.due?.let(Instant::fromEpochMilliseconds),
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SqlChunksTest {
|
||||
|
||||
@Test
|
||||
fun `ids are split so no query binds more than the limit`() {
|
||||
val ids = (1L..2_100L).toList()
|
||||
val chunks = inSelections("_id", ids, filter = "deleted = ?", filterArgs = listOf("0"))
|
||||
|
||||
assertThat(chunks.map { it.second.size }).containsExactly(999, 999, 105).inOrder()
|
||||
assertThat(chunks.flatMap { (_, args) -> args.dropLast(1) }).isEqualTo(ids.map(Long::toString))
|
||||
assertThat(chunks.all { (_, args) -> args.last() == "0" }).isTrue()
|
||||
assertThat(chunks.first().first).endsWith("?) AND (deleted = ?)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated ids are bound once, and none give no query`() {
|
||||
assertThat(inSelections("_id", listOf(3L, 3L, 4L)).single().second).asList().containsExactly("3", "4")
|
||||
assertThat(inSelections("_id", emptyList())).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SubtaskResetTest {
|
||||
@@ -79,8 +81,13 @@ class SubtaskResetTest {
|
||||
|
||||
@Test
|
||||
fun `what was reset survives encoding`() {
|
||||
val reset = listOf(ResetSubtask(2, done), ResetSubtask(3, halfway), ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null)))
|
||||
val reset = listOf(
|
||||
ResetSubtask(2, done),
|
||||
ResetSubtask(3, halfway, SubtaskDates(1L, 2L), SubtaskDates(11L, 12L)),
|
||||
ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null), SubtaskDates(null, 5L), SubtaskDates(null, 15L)),
|
||||
)
|
||||
assertThat(SubtaskReset.decode(SubtaskReset.encode(reset))).isEqualTo(reset)
|
||||
assertThat(SubtaskReset.decode("2,COMPLETED,100,1000")).containsExactly(ResetSubtask(2, done))
|
||||
assertThat(SubtaskReset.decode("")).isEmpty()
|
||||
assertThat(SubtaskReset.decode(null)).isEmpty()
|
||||
assertThat(SubtaskReset.decode("x,COMPLETED,,;2,NOPE,,;3,IN_PROCESS")).isEmpty()
|
||||
@@ -88,9 +95,91 @@ class SubtaskResetTest {
|
||||
|
||||
@Test
|
||||
fun `moving back restores only the sub-tasks still as the reset left them`() {
|
||||
val reset = listOf(ResetSubtask(2, done), ResetSubtask(3, halfway), ResetSubtask(4, done))
|
||||
val current = mapOf(2L to SubtaskState.FRESH, 3L to done)
|
||||
assertThat(SubtaskReset.toRestore(reset, current::get)).containsExactly(ResetSubtask(2, done))
|
||||
val moved = SubtaskDates(20L, null)
|
||||
val reset = listOf(
|
||||
ResetSubtask(2, done),
|
||||
ResetSubtask(3, halfway),
|
||||
ResetSubtask(4, done),
|
||||
ResetSubtask(5, done, SubtaskDates(10L, null), moved),
|
||||
ResetSubtask(6, done, SubtaskDates(10L, null), moved),
|
||||
)
|
||||
val current = mapOf(
|
||||
2L to (SubtaskState.FRESH to SubtaskDates.NONE),
|
||||
3L to (done to SubtaskDates.NONE),
|
||||
5L to (SubtaskState.FRESH to moved),
|
||||
// Re-dated since: the user's dates win.
|
||||
6L to (SubtaskState.FRESH to SubtaskDates(30L, null)),
|
||||
)
|
||||
assertThat(SubtaskReset.toRestore(reset, current::get).map { it.id }).containsExactly(2L, 5L)
|
||||
}
|
||||
|
||||
private val berlin = ZoneId.of("Europe/Berlin")
|
||||
|
||||
private fun at(text: String) = java.time.Instant.parse(text).toEpochMilli()
|
||||
|
||||
@Test
|
||||
fun `a dated sub-task keeps its wall-clock place to the routine across a DST change`() {
|
||||
// A weekly routine at 09:00 Berlin moves from the last Monday of summer time to the first of winter time.
|
||||
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin)
|
||||
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, zone = berlin)).isEqualTo(at("2026-10-27T09:00:00Z"))
|
||||
assertThat(shift.apply(at("2026-10-21T00:00:00Z"), isAllDay = true, zone = berlin)).isEqualTo(at("2026-10-28T00:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving on shifts dated sub-tasks, fresh ones included, and leaves undated fresh ones`() {
|
||||
val shift = SeriesShift(at("2026-11-02T08:00:00Z"), at("2026-11-09T08:00:00Z"), ZoneOffset.UTC)
|
||||
val nodes = listOf(
|
||||
SubtaskNode(2, false, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
|
||||
SubtaskNode(3, false, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), isAllDay = true),
|
||||
SubtaskNode(4, false, SubtaskState.FRESH),
|
||||
SubtaskNode(5, false, cancelled, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
|
||||
)
|
||||
val reset = SubtaskReset.toReset(1L, shift) { if (it == 1L) nodes else emptyList() }
|
||||
assertThat(reset).containsExactly(
|
||||
ResetSubtask(2, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null), SubtaskDates(at("2026-11-09T10:00:00Z"), null)),
|
||||
ResetSubtask(3, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), SubtaskDates(null, at("2026-11-10T00:00:00Z"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider series' shift is read in its own zone`() {
|
||||
val before = mapOf<String, Any?>(Tasks.DTSTART to at("2026-10-19T07:00:00Z"), Tasks.TZ to "Europe/Berlin", Tasks.IS_ALLDAY to 0L)
|
||||
val after = before + (Tasks.DTSTART to at("2026-10-26T08:00:00Z"))
|
||||
val shift = SubtaskReset.shiftOf(before, after, floating = ZoneOffset.UTC)!!
|
||||
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, zone = berlin)).isEqualTo(at("2026-10-27T09:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider series' rule set is read from its columns`() {
|
||||
val state = mapOf<String, Any?>(
|
||||
Tasks.DTSTART to at("2026-11-02T08:00:00Z"), Tasks.RRULE to "FREQ=WEEKLY", Tasks.EXDATE to null,
|
||||
Tasks.IS_ALLDAY to 1L, Tasks.TZ to null,
|
||||
)
|
||||
val spec = SubtaskReset.specOf(state)!!
|
||||
assertThat(spec.rrule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(spec.isAllDay).isTrue()
|
||||
assertThat(SubtaskReset.specOf(state + (Tasks.RRULE to " "))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the copy marker is recognised in a provider row and in a synced residue`() {
|
||||
assertThat(SubtaskReset.isCopyMarker(SubtaskReset.COPY_MARKER_JSON)).isTrue()
|
||||
assertThat(SubtaskReset.isCopyMarker(" [\"x-agendula-tick-copy\",\"1\"]")).isTrue()
|
||||
assertThat(SubtaskReset.isCopyMarker("[\"X-AGENDULA-TICK-COPYRIGHT\",\"1\"]")).isFalse()
|
||||
assertThat(SubtaskReset.isCopyMarker(null)).isFalse()
|
||||
assertThat(SubtaskReset.hasCopyMarker("X-OTHER:1\r\nX-AGENDULA-TICK-COPY:1")).isTrue()
|
||||
assertThat(SubtaskReset.hasCopyMarker("X-AGENDULA-TICK-COPY;X-P=1:1")).isTrue()
|
||||
assertThat(SubtaskReset.hasCopyMarker("X-AGENDULA-TICK-COPYRIGHT:1")).isFalse()
|
||||
assertThat(SubtaskReset.hasCopyMarker(null)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider tick resets only with a copy to record it in`() {
|
||||
val before = state(start) + (Tasks.RRULE to "FREQ=WEEKLY")
|
||||
val after = state(start + week) + (Tasks.RRULE to "FREQ=WEEKLY")
|
||||
assertThat(SubtaskReset.resetsOnTick(9L, before, after)).isTrue()
|
||||
assertThat(SubtaskReset.resetsOnTick(null, before, after)).isFalse()
|
||||
assertThat(SubtaskReset.resetsOnTick(9L, before, before)).isFalse()
|
||||
}
|
||||
|
||||
private val week = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TickedCopiesTest {
|
||||
|
||||
private val now = 100L * 24 * 60 * 60 * 1000
|
||||
|
||||
private fun tick(copy: Long, daysAgo: Long) = Tick(copy, seriesId = 1, before = null, after = null, at = now - daysAgo * 24 * 60 * 60 * 1000)
|
||||
|
||||
@Test
|
||||
fun `a tick past the horizon goes, and its copy stays known`() {
|
||||
val ticks = mapOf(
|
||||
"org.dmfs.tasks|1|10" to tick(copy = 7, daysAgo = 31),
|
||||
"org.dmfs.tasks|1|20" to tick(copy = 8, daysAgo = 2),
|
||||
"org.tasks.opentasks|5|10" to tick(copy = 9, daysAgo = 40),
|
||||
"garbage" to null,
|
||||
)
|
||||
|
||||
val (stale, known) = TickedCopies.expire(ticks, now)
|
||||
|
||||
assertThat(stale).containsExactly("org.dmfs.tasks|1|10", "org.tasks.opentasks|5|10", "garbage")
|
||||
assertThat(known).containsExactly("org.dmfs.tasks", setOf(7L), "org.tasks.opentasks", setOf(9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing expires inside the horizon`() {
|
||||
val (stale, known) = TickedCopies.expire(mapOf("a|1|1" to tick(copy = 3, daysAgo = 29)), now)
|
||||
assertThat(stale).isEmpty()
|
||||
assertThat(known).isEmpty()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user