Implement human-readable cron expression formatter on CronJobsScreen
This commit is contained in:
1 parent
3e093aaa72
commit
055b3381b1
3 files changed
+217
-1
No files matched your search
@@ -36,6 +36,7 @@ import com.m57.hermescontrol.ui.common.StatusBadge
|
|||||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
import com.m57.hermescontrol.util.CronExpressionFormatter
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CronJobsScreen(
|
fun CronJobsScreen(
|
||||||
@@ -122,10 +123,16 @@ fun CronJobsScreen(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
Text(
|
Text(
|
||||||
text = job.scheduleText,
|
text = CronExpressionFormatter.cronToHumanReadable(job.scheduleText),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = job.scheduleText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
object CronExpressionFormatter {
|
||||||
|
fun cronToHumanReadable(schedule: String): String {
|
||||||
|
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
|
||||||
|
val parts = clean.split(" ")
|
||||||
|
if (parts.size != 5) {
|
||||||
|
return schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val minutePart = parts[0]
|
||||||
|
val hourPart = parts[1]
|
||||||
|
val domPart = parts[2]
|
||||||
|
val monthPart = parts[3]
|
||||||
|
val dowPart = parts[4]
|
||||||
|
|
||||||
|
// 1. Check for common simple shortcuts
|
||||||
|
// Every X minutes: */X * * * *
|
||||||
|
if (minutePart.startsWith("*/") && hourPart == "*" && domPart == "*" && monthPart == "*" &&
|
||||||
|
dowPart == "*"
|
||||||
|
) {
|
||||||
|
val step = minutePart.substring(2).toIntOrNull()
|
||||||
|
if (step != null) return "Every $step minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every X hours: 0 */X * * *
|
||||||
|
if (minutePart == "0" && hourPart.startsWith("*/") && domPart == "*" && monthPart == "*" &&
|
||||||
|
dowPart == "*"
|
||||||
|
) {
|
||||||
|
val step = hourPart.substring(2).toIntOrNull()
|
||||||
|
if (step != null) return "Every $step hours"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Midnight: 0 0 * * *
|
||||||
|
if (minutePart == "0" && hourPart == "0" && domPart == "*" && monthPart == "*" && dowPart == "*") {
|
||||||
|
return "Every day at midnight"
|
||||||
|
}
|
||||||
|
|
||||||
|
// At time every day: 0 H * * * or 0 H,H2 * * *
|
||||||
|
val hoursList = parseField(hourPart, 0, 23)
|
||||||
|
val minutesList = parseField(minutePart, 0, 59)
|
||||||
|
|
||||||
|
val dowNames =
|
||||||
|
listOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
||||||
|
val monthNames =
|
||||||
|
listOf(
|
||||||
|
"",
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format time: "09:00", "midnight", etc.
|
||||||
|
fun formatTime(
|
||||||
|
h: Int,
|
||||||
|
m: Int,
|
||||||
|
): String {
|
||||||
|
if (h == 0 && m == 0) return "midnight"
|
||||||
|
return String.format("%02d:%02d", h, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for ordinals: 1st, 2nd, 3rd, 4th...
|
||||||
|
fun formatOrdinal(d: Int): String =
|
||||||
|
when {
|
||||||
|
d % 100 in 11..13 -> "${d}th"
|
||||||
|
d % 10 == 1 -> "${d}st"
|
||||||
|
d % 10 == 2 -> "${d}nd"
|
||||||
|
d % 10 == 3 -> "${d}rd"
|
||||||
|
else -> "${d}th"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day of Month / Month specific cases:
|
||||||
|
// "0 12 1 * *" -> "On the 1st of every month at 12:00"
|
||||||
|
// "0 0 1 1 *" -> "On January 1st at midnight"
|
||||||
|
if (domPart != "*" && monthPart != "*" && dowPart == "*") {
|
||||||
|
val doms = parseField(domPart, 1, 31)
|
||||||
|
val months = parseField(monthPart, 1, 12)
|
||||||
|
if (doms.size == 1 && months.size == 1 && hoursList.size == 1 && minutesList.size == 1) {
|
||||||
|
val timeStr = formatTime(hoursList[0], minutesList[0])
|
||||||
|
val monthStr = monthNames[months[0]]
|
||||||
|
val domStr = formatOrdinal(doms[0])
|
||||||
|
return if (timeStr == "midnight") {
|
||||||
|
"On $monthStr $domStr at midnight"
|
||||||
|
} else {
|
||||||
|
"On $monthStr $domStr at $timeStr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domPart != "*" && monthPart == "*" && dowPart == "*") {
|
||||||
|
val doms = parseField(domPart, 1, 31)
|
||||||
|
if (doms.size == 1 && hoursList.size == 1 && minutesList.size == 1) {
|
||||||
|
val timeStr = formatTime(hoursList[0], minutesList[0])
|
||||||
|
val domStr = formatOrdinal(doms[0])
|
||||||
|
return "On the $domStr of every month at $timeStr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days of week cases:
|
||||||
|
// "30 8 * * 1-5" -> "At 08:30 Monday through Friday"
|
||||||
|
// "0 9 * * 1" -> "Every Monday at 09:00"
|
||||||
|
if (dowPart != "*" && domPart == "*" && monthPart == "*") {
|
||||||
|
if (hoursList.size == 1 && minutesList.size == 1) {
|
||||||
|
val timeStr = formatTime(hoursList[0], minutesList[0])
|
||||||
|
if (dowPart.contains("-")) {
|
||||||
|
val bounds = dowPart.split("-")
|
||||||
|
if (bounds.size == 2) {
|
||||||
|
val start = bounds[0].toIntOrNull() ?: -1
|
||||||
|
val end = bounds[1].toIntOrNull() ?: -1
|
||||||
|
if (start in 0..7 && end in 0..7) {
|
||||||
|
val startName = dowNames[start]
|
||||||
|
val endName = dowNames[end]
|
||||||
|
return "At $timeStr $startName through $endName"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val dows = parseField(dowPart, 0, 7)
|
||||||
|
if (dows.size == 1) {
|
||||||
|
val dowName = dowNames[dows[0]]
|
||||||
|
return "Every $dowName at $timeStr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General case:
|
||||||
|
// e.g. "0 9,21 * * *" -> "At 09:00 and 21:00 every day"
|
||||||
|
if (domPart == "*" && monthPart == "*" && dowPart == "*") {
|
||||||
|
if (hoursList.isNotEmpty() && minutesList.size == 1) {
|
||||||
|
val timeStrings = hoursList.map { formatTime(it, minutesList[0]) }
|
||||||
|
val timeDesc =
|
||||||
|
if (timeStrings.size > 1) {
|
||||||
|
timeStrings.dropLast(1).joinToString(", ") + " and " + timeStrings.last()
|
||||||
|
} else {
|
||||||
|
timeStrings[0]
|
||||||
|
}
|
||||||
|
return "At $timeDesc every day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return schedule
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return schedule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseField(
|
||||||
|
field: String,
|
||||||
|
min: Int,
|
||||||
|
max: Int,
|
||||||
|
): List<Int> {
|
||||||
|
if (field == "*") return (min..max).toList()
|
||||||
|
if (field.contains(",")) {
|
||||||
|
return field
|
||||||
|
.split(",")
|
||||||
|
.flatMap { parseField(it, min, max) }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
if (field.contains("-")) {
|
||||||
|
val parts = field.split("-")
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val start = parts[0].toIntOrNull() ?: return emptyList()
|
||||||
|
val end = parts[1].toIntOrNull() ?: return emptyList()
|
||||||
|
return (start..end).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val single = field.toIntOrNull()
|
||||||
|
if (single != null) {
|
||||||
|
return listOf(single)
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CronExpressionFormatterTest {
|
||||||
|
@Test
|
||||||
|
fun testCronToHumanReadable() {
|
||||||
|
assertEquals("At 09:00 and 21:00 every day", CronExpressionFormatter.cronToHumanReadable("0 9,21 * * *"))
|
||||||
|
assertEquals("At 08:30 Monday through Friday", CronExpressionFormatter.cronToHumanReadable("30 8 * * 1-5"))
|
||||||
|
assertEquals("Every day at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 * * *"))
|
||||||
|
assertEquals("Every 15 minutes", CronExpressionFormatter.cronToHumanReadable("*/15 * * * *"))
|
||||||
|
assertEquals("Every Monday at 09:00", CronExpressionFormatter.cronToHumanReadable("0 9 * * 1"))
|
||||||
|
assertEquals("Every Sunday at 06:00", CronExpressionFormatter.cronToHumanReadable("0 6 * * 0"))
|
||||||
|
assertEquals("On the 1st of every month at 12:00", CronExpressionFormatter.cronToHumanReadable("0 12 1 * *"))
|
||||||
|
assertEquals("On January 1st at midnight", CronExpressionFormatter.cronToHumanReadable("0 0 1 1 *"))
|
||||||
|
assertEquals("Every 2 hours", CronExpressionFormatter.cronToHumanReadable("0 */2 * * *"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMalformedCron() {
|
||||||
|
assertEquals("invalid cron string", CronExpressionFormatter.cronToHumanReadable("invalid cron string"))
|
||||||
|
assertEquals("0 9 * *", CronExpressionFormatter.cronToHumanReadable("0 9 * *"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user