fix(cron): add repeat dropdown and safe touch time selection

This commit is contained in:
yilsem committed 2026-10-08 02:36:10 +03:00
1 parent 1d11afefec
commit 8161f3bf09
6 files changed
+454 -217

No files matched your search

@@ -53,6 +53,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -827,6 +828,7 @@ private fun BlueprintStartFromDropdown(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BlueprintSlotField(
field: CronBlueprintField,
@@ -858,37 +860,12 @@ private fun BlueprintSlotField(
field.type == "time" -> {
var showTimePicker by remember { mutableStateOf(false) }
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
val curHour = if (timeParts.isNotEmpty()) timeParts[0] else 8
val curMin = if (timeParts.size > 1) timeParts[1] else 0
val curHour = (timeParts.getOrNull(0) ?: 8).coerceIn(0, 23)
val curMin = (timeParts.getOrNull(1) ?: 0).coerceIn(0, 59)
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(field.label) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_time)) },
trailingIcon = {
IconButton(onClick = { showTimePicker = true }) {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = stringResource(R.string.cron_schedule_select_time),
tint = MaterialTheme.colorScheme.primary,
)
}
},
supportingText =
if (field.help.isNotBlank()) {
{
Text(
text = field.help,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
} else {
null
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(field.label, style = MaterialTheme.typography.labelLarge)
TimeDisplayCard(hour = curHour, minute = curMin, onClick = { showTimePicker = true })
if (field.help.isNotBlank()) Text(field.help, style = MaterialTheme.typography.bodySmall)
if (showTimePicker) {
val timePickerState =
@@ -899,10 +876,12 @@ private fun BlueprintSlotField(
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
onValueChange(String.format("%02d:%02d", timePickerState.hour, timePickerState.minute))
onValueChange(String.format(java.util.Locale.ROOT, "%02d:%02d", timePickerState.hour, timePickerState.minute))
showTimePicker = false
},
) {
@@ -1018,7 +997,7 @@ private fun DeliverField(
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SchedulePickerSection(
internal fun SchedulePickerSection(
schedule: String,
onScheduleChange: (String) -> Unit,
) {
@@ -1031,7 +1010,9 @@ private fun SchedulePickerSection(
var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) }
var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) }
var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) }
var isAdvanced by remember(schedule) { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
var month by remember(schedule) { mutableStateOf(parsed.month) }
var isAdvanced by remember { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
var repeatExpanded by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
fun emitSchedule(
@@ -1042,19 +1023,13 @@ private fun SchedulePickerSection(
newIntervalUnit: String = intervalUnit,
newDays: Set<Int> = selectedDays,
newDom: Int = dayOfMonth,
newMonth: Int = month,
) {
val built =
when (newMode) {
ScheduleMode.DAILY -> "$newMinute $newHour * * *"
ScheduleMode.WEEKLY -> {
val daysStr = (if (newDays.isEmpty()) listOf(1) else newDays.sorted()).joinToString(",")
"$newMinute $newHour * * $daysStr"
}
ScheduleMode.MONTHLY -> "$newMinute $newHour $newDom * *"
ScheduleMode.INTERVAL -> "every $newIntervalCount$newIntervalUnit"
ScheduleMode.ADVANCED -> schedule
}
onScheduleChange(built)
onScheduleChange(ParsedSchedule(
mode = newMode, hour = newHour, minute = newMinute,
intervalCount = newIntervalCount, intervalUnit = newIntervalUnit,
selectedDays = newDays, dayOfMonth = newDom, month = newMonth, raw = schedule,
).toSchedule())
}
Column(
@@ -1079,8 +1054,7 @@ private fun SchedulePickerSection(
mode = ScheduleMode.ADVANCED
} else {
val fallback = parseSchedule(schedule)
mode = if (fallback.mode == ScheduleMode.ADVANCED) ScheduleMode.DAILY else fallback.mode
emitSchedule(mode)
mode = fallback.mode
}
},
) {
@@ -1116,45 +1090,46 @@ private fun SchedulePickerSection(
),
)
} else {
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
val repeatOptions = listOf(
ScheduleMode.DAILY to R.string.cron_schedule_mode_daily,
ScheduleMode.HOURLY to R.string.cron_repeat_hourly,
ScheduleMode.MINUTELY to R.string.cron_repeat_minutely,
ScheduleMode.WEEKLY to R.string.cron_schedule_mode_weekly,
ScheduleMode.MONTHLY to R.string.cron_schedule_mode_monthly,
ScheduleMode.YEARLY to R.string.cron_repeat_yearly,
ScheduleMode.INTERVAL to R.string.cron_schedule_mode_interval,
)
ExposedDropdownMenuBox(
expanded = repeatExpanded,
onExpandedChange = { repeatExpanded = it },
) {
FilterChip(
selected = mode == ScheduleMode.DAILY,
onClick = {
mode = ScheduleMode.DAILY
emitSchedule(ScheduleMode.DAILY)
},
label = { Text(stringResource(R.string.cron_schedule_mode_daily)) },
)
FilterChip(
selected = mode == ScheduleMode.WEEKLY,
onClick = {
mode = ScheduleMode.WEEKLY
emitSchedule(ScheduleMode.WEEKLY)
},
label = { Text(stringResource(R.string.cron_schedule_mode_weekly)) },
)
FilterChip(
selected = mode == ScheduleMode.INTERVAL,
onClick = {
mode = ScheduleMode.INTERVAL
emitSchedule(ScheduleMode.INTERVAL)
},
label = { Text(stringResource(R.string.cron_schedule_mode_interval)) },
)
FilterChip(
selected = mode == ScheduleMode.MONTHLY,
onClick = {
mode = ScheduleMode.MONTHLY
emitSchedule(ScheduleMode.MONTHLY)
},
label = { Text(stringResource(R.string.cron_schedule_mode_monthly)) },
OutlinedTextField(
value = stringResource(repeatOptions.firstOrNull { it.first == mode }?.second
?: R.string.cron_schedule_advanced_toggle),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.cron_repeat_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = repeatExpanded) },
modifier = Modifier.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = repeatExpanded, onDismissRequest = { repeatExpanded = false }) {
repeatOptions.forEach { (option, label) ->
DropdownMenuItem(
text = { Text(stringResource(label)) },
onClick = {
mode = option
repeatExpanded = false
emitSchedule(option)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.cron_repeat_seconds_unavailable)) },
onClick = {},
enabled = false,
)
}
}
when (mode) {
@@ -1178,6 +1153,7 @@ private fun SchedulePickerSection(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TextButton(
@@ -1249,6 +1225,27 @@ private fun SchedulePickerSection(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_count),
options = ((1..120).toList() + intervalCount).distinct().sorted().map { it.toString() },
selectedValue = intervalCount.toString(),
onOptionSelected = {
intervalCount = it.toInt()
emitSchedule(mode, newIntervalCount = intervalCount)
},
)
val units = listOf("m", "h", "d")
val labels = listOf(stringResource(R.string.cron_unit_minute),
stringResource(R.string.cron_unit_hour), stringResource(R.string.cron_unit_day))
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_unit),
options = labels,
selectedValue = labels[units.indexOf(intervalUnit)],
onOptionSelected = {
intervalUnit = units[labels.indexOf(it)]
emitSchedule(mode, newIntervalUnit = intervalUnit)
},
)
Text(
text = stringResource(R.string.cron_schedule_interval_presets),
style = MaterialTheme.typography.labelSmall,
@@ -1256,6 +1253,7 @@ private fun SchedulePickerSection(
)
val presets =
listOf(
Triple(1, "m", "1 " + stringResource(R.string.cron_unit_minute)),
Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)),
Triple(30, "m", "30 " + stringResource(R.string.cron_unit_minute)),
Triple(1, "h", "1 " + stringResource(R.string.cron_unit_hour)),
@@ -1287,7 +1285,20 @@ private fun SchedulePickerSection(
}
}
ScheduleMode.MONTHLY -> {
ScheduleMode.MONTHLY, ScheduleMode.YEARLY -> {
if (mode == ScheduleMode.YEARLY) {
val months = java.text.DateFormatSymbols(LocalConfiguration.current.locales[0]).months.take(12)
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_month),
options = months,
selectedValue = months[month - 1],
onOptionSelected = {
month = months.indexOf(it) + 1
dayOfMonth = dayOfMonth.coerceAtMost(java.time.Month.of(month).maxLength())
emitSchedule(mode, newDom = dayOfMonth, newMonth = month)
},
)
}
TimeDisplayCard(
hour = hour,
minute = minute,
@@ -1311,7 +1322,7 @@ private fun SchedulePickerSection(
onClick = {
if (dayOfMonth > 1) {
dayOfMonth--
emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth)
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth > 1,
@@ -1325,12 +1336,13 @@ private fun SchedulePickerSection(
)
IconButton(
onClick = {
if (dayOfMonth < 31) {
val maxDay = if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31
if (dayOfMonth < maxDay) {
dayOfMonth++
emitSchedule(ScheduleMode.MONTHLY, newDom = dayOfMonth)
emitSchedule(mode, newDom = dayOfMonth)
}
},
enabled = dayOfMonth < 31,
enabled = dayOfMonth < (if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31),
) {
Text("+", style = MaterialTheme.typography.titleLarge)
}
@@ -1338,11 +1350,28 @@ private fun SchedulePickerSection(
}
}
ScheduleMode.ADVANCED -> Unit
ScheduleMode.HOURLY -> {
ExposedDropdownField(
label = stringResource(R.string.cron_repeat_minute),
options = (0..59).map { it.toString().padStart(2, '0') },
selectedValue = minute.toString().padStart(2, '0'),
onOptionSelected = { minute = it.toInt(); emitSchedule(mode, newMinute = minute) },
)
}
ScheduleMode.MINUTELY, ScheduleMode.ADVANCED -> Unit
}
if ((mode == ScheduleMode.MONTHLY && dayOfMonth > 28) ||
(mode == ScheduleMode.YEARLY && month == 2 && dayOfMonth == 29)) {
Text(stringResource(R.string.cron_repeat_calendar_note), style = MaterialTheme.typography.bodySmall)
}
}
// Live Summary Card
Text(
stringResource(R.string.cron_schedule_timezone_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -1366,18 +1395,10 @@ private fun SchedulePickerSection(
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = CronExpressionFormatter.cronToHumanReadable(schedule.ifBlank { "0 9 * * *" }),
text = scheduleSummary(parsed, schedule),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
text = schedule.ifBlank { "0 9 * * *" },
style =
MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
}
@@ -1392,6 +1413,8 @@ private fun SchedulePickerSection(
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth().padding(8.dp),
confirmButton = {
TextButton(
onClick = {
@@ -1453,7 +1476,7 @@ private fun TimeDisplayCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = String.format("%02d:%02d", hour, minute),
text = String.format(java.util.Locale.ROOT, "%02d:%02d", hour, minute),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
@@ -1469,121 +1492,29 @@ private fun TimeDisplayCard(
}
}
private enum class ScheduleMode {
DAILY,
WEEKLY,
INTERVAL,
MONTHLY,
ADVANCED,
}
private data class ParsedSchedule(
val mode: ScheduleMode,
val hour: Int = 9,
val minute: Int = 0,
val intervalCount: Int = 1,
val intervalUnit: String = "h",
val selectedDays: Set<Int> = setOf(1, 2, 3, 4, 5),
val dayOfMonth: Int = 1,
val raw: String = "",
)
private fun parseSchedule(schedule: String): ParsedSchedule {
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
if (clean.isBlank()) {
return ParsedSchedule(mode = ScheduleMode.DAILY, hour = 9, minute = 0)
}
val everyMatch = Regex("""^every\s+(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(clean)
if (everyMatch != null) {
val count = everyMatch.groupValues[1].toIntOrNull() ?: 1
val unit = everyMatch.groupValues[2].lowercase()
return ParsedSchedule(
mode = ScheduleMode.INTERVAL,
intervalCount = count,
intervalUnit = unit,
)
}
val minStepMatch = Regex("""^\*/(\d+)\s+\*\s+\*\s+\*\s+\*$""").find(clean)
if (minStepMatch != null) {
val count = minStepMatch.groupValues[1].toIntOrNull() ?: 15
return ParsedSchedule(
mode = ScheduleMode.INTERVAL,
intervalCount = count,
intervalUnit = "m",
)
}
val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean)
if (hourStepMatch != null) {
val count = hourStepMatch.groupValues[1].toIntOrNull() ?: 1
return ParsedSchedule(
mode = ScheduleMode.INTERVAL,
intervalCount = count,
intervalUnit = "h",
)
}
val parts = clean.split(" ")
if (parts.size == 5) {
val minute = parts[0].toIntOrNull()
val hour = parts[1].toIntOrNull()
val dom = parts[2]
val month = parts[3]
val dow = parts[4]
if (minute != null && hour != null && minute in 0..59 && hour in 0..23) {
if (dom == "*" && month == "*" && dow == "*") {
return ParsedSchedule(
mode = ScheduleMode.DAILY,
hour = hour,
minute = minute,
)
}
if (dom == "*" && month == "*" && dow != "*") {
val days = parseDowList(dow)
if (days.isNotEmpty()) {
return ParsedSchedule(
mode = ScheduleMode.WEEKLY,
hour = hour,
minute = minute,
selectedDays = days.toSet(),
)
}
}
if (dom != "*" && month == "*" && dow == "*") {
val domInt = dom.toIntOrNull()
if (domInt != null && domInt in 1..31) {
return ParsedSchedule(
mode = ScheduleMode.MONTHLY,
hour = hour,
minute = minute,
dayOfMonth = domInt,
)
}
}
@Composable
private fun scheduleSummary(parsed: ParsedSchedule, raw: String): String {
val time = java.lang.String.format(java.util.Locale.ROOT, "%02d:%02d", parsed.hour, parsed.minute)
return when (parsed.mode) {
ScheduleMode.DAILY -> stringResource(R.string.cron_summary_daily, time)
ScheduleMode.HOURLY -> stringResource(R.string.cron_summary_hourly, parsed.minute)
ScheduleMode.MINUTELY -> stringResource(R.string.cron_repeat_minutely)
ScheduleMode.WEEKLY -> {
val days = listOf(R.string.cron_dow_sun, R.string.cron_dow_mon, R.string.cron_dow_tue,
R.string.cron_dow_wed, R.string.cron_dow_thu, R.string.cron_dow_fri, R.string.cron_dow_sat)
val names = parsed.selectedDays.sorted().map { stringResource(days[it]) }.joinToString(", ")
stringResource(R.string.cron_summary_weekly, names, time)
}
}
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
private fun parseDowList(dow: String): List<Int> {
if (dow.contains("-")) {
val bounds = dow.split("-")
if (bounds.size == 2) {
val start = bounds[0].toIntOrNull() ?: return emptyList()
val end = bounds[1].toIntOrNull() ?: return emptyList()
if (start in 0..7 && end in 0..7 && start <= end) {
return (start..end).map { if (it == 7) 0 else it }.distinct().sorted()
}
ScheduleMode.MONTHLY -> stringResource(R.string.cron_summary_monthly, parsed.dayOfMonth, time)
ScheduleMode.YEARLY -> stringResource(R.string.cron_summary_yearly, parsed.dayOfMonth, parsed.month, time)
ScheduleMode.INTERVAL -> {
val unit = stringResource(when (parsed.intervalUnit) {
"m" -> R.string.cron_unit_minute
"h" -> R.string.cron_unit_hour
else -> R.string.cron_unit_day
})
stringResource(R.string.cron_summary_interval, parsed.intervalCount, unit)
}
ScheduleMode.ADVANCED -> raw
}
return dow.split(",")
.mapNotNull { it.trim().toIntOrNull() }
.filter { it in 0..7 }
.map { if (it == 7) 0 else it }
.distinct()
.sorted()
}
@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.cron
internal enum class ScheduleMode {
HOURLY,
MINUTELY,
YEARLY,
DAILY,
WEEKLY,
INTERVAL,
MONTHLY,
ADVANCED,
}
internal data class ParsedSchedule(
val mode: ScheduleMode,
val hour: Int = 9,
val minute: Int = 0,
val intervalCount: Int = 1,
val intervalUnit: String = "h",
val selectedDays: Set<Int> = setOf(1, 2, 3, 4, 5),
val dayOfMonth: Int = 1,
val month: Int = 1,
val raw: String = "",
)
internal fun parseSchedule(schedule: String): ParsedSchedule {
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
if (clean.isBlank()) {
return ParsedSchedule(mode = ScheduleMode.DAILY, hour = 9, minute = 0)
}
val everyMatch = Regex("""^every\s+(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(clean)
if (everyMatch != null) {
val count = everyMatch.groupValues[1].toIntOrNull()
?: return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
if (count <= 0) return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
val unit = everyMatch.groupValues[2].lowercase()
return ParsedSchedule(
mode = ScheduleMode.INTERVAL,
intervalCount = count,
intervalUnit = unit,
)
}
val minStepMatch = Regex("""^\*/(\d+)\s+\*\s+\*\s+\*\s+\*$""").find(clean)
if (minStepMatch != null) {
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean)
if (hourStepMatch != null) {
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
val parts = clean.split(" ")
if (clean == "* * * * *") return ParsedSchedule(mode = ScheduleMode.MINUTELY)
if (parts.size == 5 && parts.drop(1).all { it == "*" }) {
val minute = parts[0].toIntOrNull()
if (minute != null && minute in 0..59) return ParsedSchedule(mode = ScheduleMode.HOURLY, minute = minute)
}
if (parts.size == 5) {
val minute = parts[0].toIntOrNull()
val hour = parts[1].toIntOrNull()
val dom = parts[2]
val month = parts[3]
val dow = parts[4]
if (minute != null && hour != null && minute in 0..59 && hour in 0..23) {
val monthNumber = month.toIntOrNull()
val dayNumber = dom.toIntOrNull()
if (monthNumber != null && monthNumber in 1..12 && dayNumber != null &&
dayNumber in 1..java.time.Month.of(monthNumber).maxLength() && dow == "*") {
return ParsedSchedule(ScheduleMode.YEARLY, hour = hour, minute = minute,
dayOfMonth = dayNumber, month = monthNumber)
}
if (dom == "*" && month == "*" && dow == "*") {
return ParsedSchedule(
mode = ScheduleMode.DAILY,
hour = hour,
minute = minute,
)
}
if (dom == "*" && month == "*" && dow != "*") {
val days = parseDowList(dow)
if (days.isNotEmpty()) {
return ParsedSchedule(
mode = ScheduleMode.WEEKLY,
hour = hour,
minute = minute,
selectedDays = days.toSet(),
)
}
}
if (dom != "*" && month == "*" && dow == "*") {
val domInt = dom.toIntOrNull()
if (domInt != null && domInt in 1..31) {
return ParsedSchedule(
mode = ScheduleMode.MONTHLY,
hour = hour,
minute = minute,
dayOfMonth = domInt,
)
}
}
}
}
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
}
internal fun ParsedSchedule.toSchedule(): String {
require(hour in 0..23 && minute in 0..59)
require(dayOfMonth in 1..31 && month in 1..12)
return when (mode) {
ScheduleMode.DAILY -> "$minute $hour * * *"
ScheduleMode.HOURLY -> "$minute * * * *"
ScheduleMode.MINUTELY -> "* * * * *"
ScheduleMode.WEEKLY -> {
require(selectedDays.isNotEmpty() && selectedDays.all { it in 0..6 })
"$minute $hour * * ${selectedDays.sorted().joinToString(",")}"
}
ScheduleMode.MONTHLY -> "$minute $hour $dayOfMonth * *"
ScheduleMode.YEARLY -> {
require(dayOfMonth <= java.time.Month.of(month).maxLength())
"$minute $hour $dayOfMonth $month *"
}
ScheduleMode.INTERVAL -> {
require(intervalCount > 0 && intervalUnit in listOf("m", "h", "d"))
"every $intervalCount$intervalUnit"
}
ScheduleMode.ADVANCED -> raw
}
}
private fun parseDowList(dow: String): List<Int> {
if (dow.contains("-")) {
val bounds = dow.split("-")
if (bounds.size == 2) {
val start = bounds[0].toIntOrNull() ?: return emptyList()
val end = bounds[1].toIntOrNull() ?: return emptyList()
if (start in 0..7 && end in 0..7 && start <= end) {
return (start..end).map { if (it == 7) 0 else it }.distinct().sorted()
}
}
}
val values = dow.split(",").map { it.trim().toIntOrNull() ?: return emptyList() }
if (values.any { it !in 0..7 }) return emptyList()
return values
.map { if (it == 7) 0 else it }
.distinct()
.sorted()
}