diff --git a/CHANGELOG.md b/CHANGELOG.md index 3400205..24fe066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.5.2 + +* Versions + * `Kotlin`: `1.4.31` -> `1.4.32` +* Supporting of weekdays +* Supporting of timezones + * Any `KronScheduler` now can be used for calling `next` with `DateTimeTz` + * New type `KronSchedulerTz` +* `SchedulerFlow` has been deprecated +* New extension `asTzFlow` and small changes in `asFlow` logic +* `merge` extensions now return `CollectionKronScheduler` instead of just `KronScheduler` + ## 0.5.1 * Versions diff --git a/README.md b/README.md index 376cb2e..ee5a487 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ runtime of applications. | [ How to use: Config from string ](#config-from-string) | | [ How to use: Config via builder (DSL preview) ](#config-via-builder) | | [ How to use: KronScheduler as a Flow ](#KronScheduler-as-a-Flow) | +| [ How to use: Offsets ](#Offsets) | +| [ How to use: Note about week days ](#Note-about-week-days) | ## How to use @@ -52,14 +54,17 @@ For old version of Gradle, instead of `implementation` word developers must use Developers can use more simple way to configure repeat times is string. String configuring like a `crontab`, but with a little bit different meanings: + ``` -/---------- Seconds -| /-------- Minutes -| | /------ Hours -| | | /---- Days of months -| | | | /-- Months -| | | | | / (optional) Year -* * * * * * +/--------------- Seconds +| /------------- Minutes +| | /----------- Hours +| | | /--------- Days of months +| | | | /------- Months +| | | | | /----- (optional) Year +| | | | | | /--- (optional) Timezone offset +| | | | | | | / (optional) Week days +* * * * * * 0o *w ``` It is different with original `crontab` syntax for the reason, that expected that in practice developers @@ -152,3 +157,27 @@ flow.takeWhile { action() } ``` + +### Offsets + +Offsets in this library works via passing parameter ending with `o` in any place after `month` config. Currently +there is only one format supported for offsets: minutes of offsets. To use time zones you will need to call `next` +method with `DateTimeTz` argument or `nextTimeZoned` method with any `KronScheduler` instance, but in case if this +scheduler is not instance of `KronSchedulerTz` it will works like you passed just `DateTime`. + +Besides, in case you wish to use time zones explicitly, you will need to get `KronSchedulerTz`. It is possible by: + +* Using `createSimpleScheduler`/`buildSchedule`/`KrontabTemplate#toSchedule`/`KrontabTemplate#toKronScheduler` methods +with passing `defaultOffset` parameter +* Using `SchedulerBuilder#build`/`createSimpleScheduler`/`buildSchedule`/`KrontabTemplate#toSchedule`/`KrontabTemplate#toKronScheduler` +methods with casting to `KronSchedulerTz` in case you are pretty sure that it is timezoned `KronScheduler` +* Creating your own implementation of `KronSchedulerTz` + +### Note about week days + +Unlike original CRON, here week days: + +* Works as `AND`: cron date time will search first day which will pass requirement according all parameters including +week days +* You may use any related to numbers syntax with week days: `0-3w`, `0,1,2,3w`, etc. +* Week days (like years and offsets) are optional and can be placed anywhere after `month` diff --git a/build.gradle b/build.gradle index 44f9d63..6ab9cc0 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,18 @@ plugins { id "org.jetbrains.kotlin.multiplatform" version "$kotlin_version" id "org.jetbrains.dokka" version "$dokka_version" } + +// temporal crutch until legacy tests will be stabled or legacy target will be removed +allprojects { + if (it != rootProject.findProject("docs")) { + tasks.whenTaskAdded { task -> + if(task.name == "jsLegacyBrowserTest" || task.name == "jsLegacyNodeTest") { + task.enabled = false + } + } + } +} + apply plugin: "com.android.library" project.version = "$version" diff --git a/gradle.properties b/gradle.properties index 06a6ce4..3a825fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,10 +8,10 @@ android.useAndroidX=true android.enableJetifier=false -kotlin_version=1.4.31 +kotlin_version=1.4.32 kotlin_coroutines_version=1.4.3 -dokka_version=1.4.20 +dokka_version=1.4.30 klockVersion=2.0.7 @@ -33,6 +33,6 @@ androidx_work_version=2.5.0 ## Common -version=0.5.1 -android_code_version=2 +version=0.5.2 +android_code_version=3 diff --git a/src/commonMain/kotlin/dev/inmo/krontab/KronScheduler.kt b/src/commonMain/kotlin/dev/inmo/krontab/KronScheduler.kt index b7e214a..5dc31c7 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/KronScheduler.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/KronScheduler.kt @@ -1,7 +1,6 @@ package dev.inmo.krontab import com.soywiz.klock.DateTime -import dev.inmo.krontab.internal.toNearDateTime /** * This interface was created for abstraction of [next] operation. Currently, there is only diff --git a/src/commonMain/kotlin/dev/inmo/krontab/KronSchedulerTz.kt b/src/commonMain/kotlin/dev/inmo/krontab/KronSchedulerTz.kt new file mode 100644 index 0000000..16867bb --- /dev/null +++ b/src/commonMain/kotlin/dev/inmo/krontab/KronSchedulerTz.kt @@ -0,0 +1,34 @@ +package dev.inmo.krontab + +import com.soywiz.klock.DateTime +import com.soywiz.klock.DateTimeTz + +/** + * This interface extending [KronScheduler] to use [DateTimeTz] with taking into account offset of incoming time for + * [next] operation. + * + * @see dev.inmo.krontab.internal.CronDateTimeScheduler + * @see dev.inmo.krontab.KronScheduler + */ +interface KronSchedulerTz : KronScheduler { + suspend fun next(relatively: DateTimeTz): DateTimeTz? + + override suspend fun next(relatively: DateTime): DateTime? = next(relatively.localUnadjusted) ?.local +} + +suspend fun KronSchedulerTz.nextOrRelative(relatively: DateTimeTz): DateTimeTz = next(relatively) ?: getAnyNext( + relatively.local +).toOffsetUnadjusted(relatively.offset) +suspend fun KronSchedulerTz.nextOrNowWithOffset(): DateTimeTz = DateTimeTz.nowLocal().let { + next(it) ?: getAnyNext( + it.local + ).toOffsetUnadjusted(it.offset) +} + +suspend fun KronScheduler.next(relatively: DateTimeTz) = if (this is KronSchedulerTz) { + this.next(relatively) +} else { + this.next(relatively.local) ?.toOffsetUnadjusted(relatively.offset) +} + +suspend fun KronScheduler.nextTimeZoned() = next(DateTime.now().local) \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/inmo/krontab/KronSchedulersMerging.kt b/src/commonMain/kotlin/dev/inmo/krontab/KronSchedulersMerging.kt index f5a682f..e246e6b 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/KronSchedulersMerging.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/KronSchedulersMerging.kt @@ -1,8 +1,8 @@ package dev.inmo.krontab import dev.inmo.krontab.collection.CollectionKronScheduler -import dev.inmo.krontab.internal.CronDateTime -import dev.inmo.krontab.internal.CronDateTimeScheduler +import dev.inmo.krontab.collection.includeAll +import dev.inmo.krontab.internal.* /** * Create new one [CollectionKronScheduler] to include all [KronScheduler]s of [this] [Iterator] @@ -10,18 +10,23 @@ import dev.inmo.krontab.internal.CronDateTimeScheduler * @see CollectionKronScheduler * @see CollectionKronScheduler.include */ -fun Iterator.merge(): KronScheduler { +fun Iterator.merge(): CollectionKronScheduler { val cronDateTimes = mutableListOf() + val timezonedCronDateTimes = mutableListOf() val collectionScheduler = CollectionKronScheduler() forEach { when (it) { is CronDateTimeScheduler -> cronDateTimes.addAll(it.cronDateTimes) + is CronDateTimeSchedulerTz -> timezonedCronDateTimes.add(it) else -> collectionScheduler.include(it) } } if (cronDateTimes.isNotEmpty()) { collectionScheduler.include(CronDateTimeScheduler(cronDateTimes)) } + if (timezonedCronDateTimes.isNotEmpty()) { + collectionScheduler.includeAll(mergeCronDateTimeSchedulers(timezonedCronDateTimes)) + } return collectionScheduler } @@ -32,10 +37,10 @@ fun Iterator.merge(): KronScheduler { * @see CollectionKronScheduler.include */ @Suppress("NOTHING_TO_INLINE") -inline fun Iterable.merge(): KronScheduler = iterator().merge() +inline fun Iterable.merge(): CollectionKronScheduler = iterator().merge() /** * @return Vararg shortcut for [merge] */ @Suppress("NOTHING_TO_INLINE") -inline fun merge(vararg kronDateTimeSchedulers: KronScheduler) = kronDateTimeSchedulers.iterator().merge() +inline fun merge(vararg kronDateTimeSchedulers: KronScheduler): CollectionKronScheduler = kronDateTimeSchedulers.iterator().merge() diff --git a/src/commonMain/kotlin/dev/inmo/krontab/SchedulerShortcuts.kt b/src/commonMain/kotlin/dev/inmo/krontab/SchedulerShortcuts.kt index ed641e6..e05a29d 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/SchedulerShortcuts.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/SchedulerShortcuts.kt @@ -3,8 +3,6 @@ package dev.inmo.krontab import com.soywiz.klock.DateTime import dev.inmo.krontab.builder.buildSchedule import dev.inmo.krontab.internal.* -import dev.inmo.krontab.internal.CronDateTime -import dev.inmo.krontab.internal.CronDateTimeScheduler internal val anyCronDateTime by lazy { CronDateTime() diff --git a/src/commonMain/kotlin/dev/inmo/krontab/StringParser.kt b/src/commonMain/kotlin/dev/inmo/krontab/StringParser.kt index d41c5b4..b9f29ad 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/StringParser.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/StringParser.kt @@ -1,6 +1,9 @@ package dev.inmo.krontab +import com.soywiz.klock.TimezoneOffset +import com.soywiz.klock.minutes import dev.inmo.krontab.internal.* +import dev.inmo.krontab.utils.Minutes /** * @see createSimpleScheduler @@ -17,8 +20,10 @@ typealias KrontabTemplate = String * * dayOfMonth * * month * * (optional) year + * * (optional) (can be placed anywhere after month) (must be marked with `o` at the end, for example: 60o == +01:00) offset + * * (optional) (can be placed anywhere after month) dayOfWeek * - * And each one have next format: + * And each one (except of offsets) have next format: * * `{number}[,{number},...]` or `*` * @@ -31,6 +36,9 @@ typealias KrontabTemplate = String * * F * * L * + * Week days must be marked with `w` at the end, and starts with 0 which means Sunday. For example, 0w == Sunday. With + * weeks you can use syntax like with any number like seconds, for example: 0-2w means Sunday-Tuesday + * * Additional info about ranges can be found in follow accordance: * * * Seconds ranges can be found in [secondsRange] @@ -39,6 +47,7 @@ typealias KrontabTemplate = String * * Days of month ranges can be found in [dayOfMonthRange] * * Months ranges can be found in [monthRange] * * Years ranges can be found in [yearRange] (in fact - any [Int]) + * * WeekDay (timezone) ranges can be found in [dayOfWeekRange] * * Examples: * @@ -46,14 +55,41 @@ typealias KrontabTemplate = String * * "0/5,L * * * *" for every five seconds triggering and on 59 second * * "0/15 30 * * *" for every 15th seconds in a half of each hour * * "1 2 3 F,4,L 5" for triggering in near first second of second minute of third hour of fourth day of may + * * "1 2 3 F,4,L 5 60o" for triggering in near first second of second minute of third hour of fourth day of may with timezone UTC+01:00 + * * "1 2 3 F,4,L 5 60o 0-2w" for triggering in near first second of second minute of third hour of fourth day of may in case if it will be in Sunday-Tuesday week days with timezone UTC+01:00 * * "1 2 3 F,4,L 5 2021" for triggering in near first second of second minute of third hour of fourth day of may of 2021st year + * * "1 2 3 F,4,L 5 2021 60o" for triggering in near first second of second minute of third hour of fourth day of may of 2021st year with timezone UTC+01:00 + * * "1 2 3 F,4,L 5 2021 60o 0-2w" for triggering in near first second of second minute of third hour of fourth day of may of 2021st year if it will be in Sunday-Tuesday week days with timezone UTC+01:00 + * + * @return In case when offset parameter is absent in [incoming] will be used [createSimpleScheduler] method and + * returned [CronDateTimeScheduler]. In case when offset parameter there is in [incoming] [KrontabTemplate] will be used + * [createKronSchedulerWithOffset] and returned [CronDateTimeSchedulerTz] * * @see dev.inmo.krontab.internal.createKronScheduler */ -fun createSimpleScheduler(incoming: KrontabTemplate): KronScheduler { - val yearSource: String? +fun createSimpleScheduler( + incoming: KrontabTemplate +): KronScheduler { + var offsetParsed: Int? = null + var dayOfWeekParsed: Array? = null + var yearParsed: Array? = null val (secondsSource, minutesSource, hoursSource, dayOfMonthSource, monthSource) = incoming.split(" ").also { - yearSource = it.getOrNull(5) + listOfNotNull( + it.getOrNull(5), + it.getOrNull(6), + it.getOrNull(7) + ).forEach { + val offsetFromString = parseOffset(it) + val dayOfWeekFromString = parseWeekDay(it) + offsetParsed = offsetParsed ?: offsetFromString + dayOfWeekParsed = dayOfWeekParsed ?: dayOfWeekFromString + when { + dayOfWeekFromString != null || offsetFromString != null -> return@forEach + yearParsed == null -> { + yearParsed = parseYears(it) + } + } + } } val secondsParsed = parseSeconds(secondsSource) @@ -61,24 +97,54 @@ fun createSimpleScheduler(incoming: KrontabTemplate): KronScheduler { val hoursParsed = parseHours(hoursSource) val dayOfMonthParsed = parseDaysOfMonth(dayOfMonthSource) val monthParsed = parseMonths(monthSource) - val yearParsed = parseYears(yearSource) - return createKronScheduler( - secondsParsed, minutesParsed, hoursParsed, dayOfMonthParsed, monthParsed, yearParsed + return offsetParsed ?.let { offset -> + createKronSchedulerWithOffset( + secondsParsed, minutesParsed, hoursParsed, dayOfMonthParsed, monthParsed, yearParsed, dayOfWeekParsed, TimezoneOffset(offset.minutes) + ) + } ?: createKronScheduler( + secondsParsed, minutesParsed, hoursParsed, dayOfMonthParsed, monthParsed, yearParsed, dayOfWeekParsed ) } +fun createSimpleScheduler( + incoming: KrontabTemplate, + defaultOffset: Minutes +): KronSchedulerTz { + val scheduler = createSimpleScheduler(incoming) + return if (scheduler is KronSchedulerTz) { + scheduler + } else { + CronDateTimeSchedulerTz( + (scheduler as CronDateTimeScheduler).cronDateTimes, + TimezoneOffset(defaultOffset.minutes) + ) + } +} + /** * Shortcut for [createSimpleScheduler] */ fun buildSchedule(incoming: KrontabTemplate): KronScheduler = createSimpleScheduler(incoming) +/** + * Shortcut for [createSimpleScheduler] + */ +fun buildSchedule(incoming: KrontabTemplate, defaultOffset: Minutes): KronSchedulerTz = createSimpleScheduler(incoming, defaultOffset) /** * Shortcut for [buildSchedule] */ fun KrontabTemplate.toSchedule(): KronScheduler = buildSchedule(this) +/** + * Shortcut for [buildSchedule] + */ +fun KrontabTemplate.toSchedule(defaultOffset: Minutes): KronSchedulerTz = buildSchedule(this, defaultOffset) /** * Shortcut for [buildSchedule] */ -fun KrontabTemplate.toKronScheduler(): KronScheduler = buildSchedule(this) \ No newline at end of file +fun KrontabTemplate.toKronScheduler(): KronScheduler = buildSchedule(this) +/** + * Shortcut for [buildSchedule] + */ +fun KrontabTemplate.toKronScheduler(defaultOffset: Minutes): KronSchedulerTz = buildSchedule(this, defaultOffset) diff --git a/src/commonMain/kotlin/dev/inmo/krontab/builder/SchedulerBuilder.kt b/src/commonMain/kotlin/dev/inmo/krontab/builder/SchedulerBuilder.kt index acc1334..768a127 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/builder/SchedulerBuilder.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/builder/SchedulerBuilder.kt @@ -1,7 +1,12 @@ package dev.inmo.krontab.builder +import com.soywiz.klock.TimezoneOffset +import com.soywiz.klock.minutes import dev.inmo.krontab.KronScheduler +import dev.inmo.krontab.KronSchedulerTz import dev.inmo.krontab.internal.createKronScheduler +import dev.inmo.krontab.internal.createKronSchedulerWithOffset +import dev.inmo.krontab.utils.Minutes /** * Will help to create an instance of [KronScheduler] @@ -16,13 +21,31 @@ fun buildSchedule(settingsBlock: SchedulerBuilder.() -> Unit): KronScheduler { return builder.build() } +/** + * Will help to create an instance of [KronScheduler] + * + * @see dev.inmo.krontab.createSimpleScheduler + */ +fun buildSchedule( + offset: Minutes, + settingsBlock: SchedulerBuilder.() -> Unit +): KronSchedulerTz { + val builder = SchedulerBuilder(offset = offset) + + builder.settingsBlock() + + return builder.build() as KronSchedulerTz +} + class SchedulerBuilder( private var seconds: Array? = null, private var minutes: Array? = null, private var hours: Array? = null, private var dayOfMonth: Array? = null, private var month: Array? = null, - private var year: Array? = null + private var year: Array? = null, + private var dayOfWeek: Array? = null, + private val offset: Minutes? = null ) { private fun > callAndReturn( initial: Array?, @@ -84,6 +107,17 @@ class SchedulerBuilder( ) ?.toTypedArray() } + /** + * Starts an hours block + */ + fun dayOfWeek(block: WeekDaysBuilder.() -> Unit) { + dayOfWeek = callAndReturn( + dayOfWeek, + WeekDaysBuilder(), + block + ) ?.toTypedArray() + } + /** * Starts an months block */ @@ -112,5 +146,7 @@ class SchedulerBuilder( * @see dev.inmo.krontab.createSimpleScheduler * @see dev.inmo.krontab.internal.createKronScheduler */ - fun build(): KronScheduler = createKronScheduler(seconds, minutes, hours, dayOfMonth, month, year) + fun build(): KronScheduler = offset ?.let { + createKronSchedulerWithOffset(seconds, minutes, hours, dayOfMonth, month, year, dayOfWeek, TimezoneOffset(it.minutes)) + } ?: createKronScheduler(seconds, minutes, hours, dayOfMonth, month, year, dayOfWeek) } diff --git a/src/commonMain/kotlin/dev/inmo/krontab/builder/TimeBuilder.kt b/src/commonMain/kotlin/dev/inmo/krontab/builder/TimeBuilder.kt index c4cc8a4..ad21c6d 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/builder/TimeBuilder.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/builder/TimeBuilder.kt @@ -1,11 +1,10 @@ package dev.inmo.krontab.builder import dev.inmo.krontab.internal.* -import dev.inmo.krontab.utils.clamp /** * This class was created for incapsulation of builder work with specified [restrictionsRange]. For example, - * [include] function of [TimeBuilder] will always [clamp] incoming data using its [restrictionsRange] + * [include] function of [TimeBuilder] will always [coerceIn] incoming data using its [restrictionsRange] */ sealed class TimeBuilder ( private val restrictionsRange: IntRange, @@ -37,7 +36,7 @@ sealed class TimeBuilder ( */ @Suppress("MemberVisibilityCanBePrivate") infix fun include(array: Array) { - val clamped = array.map { it.clamp(restrictionsRange) } + (result ?: emptySet()) + val clamped = array.map { it.coerceIn(restrictionsRange) } + (result ?: emptySet()) result = clamped.toSet() } @@ -46,7 +45,7 @@ sealed class TimeBuilder ( */ @Suppress("unused") infix fun at(value: Int) { - result = (result ?: emptySet()) + value.clamp(restrictionsRange) + result = (result ?: emptySet()) + value.coerceIn(restrictionsRange) } @@ -70,7 +69,7 @@ sealed class TimeBuilder ( * @see [from] */ infix fun Int.every(delay: Int): Array { - val progression = clamp(restrictionsRange) .. restrictionsRange.last step delay + val progression = coerceIn(restrictionsRange) .. restrictionsRange.last step delay val result = progression.toSet().toTypedArray() this@TimeBuilder include result @@ -88,7 +87,7 @@ sealed class TimeBuilder ( */ @Suppress("MemberVisibilityCanBePrivate") infix fun Int.upTo(endIncluding: Int): Array { - val progression = clamp(restrictionsRange) .. endIncluding.clamp(restrictionsRange) + val progression = coerceIn(restrictionsRange) .. endIncluding.coerceIn(restrictionsRange) val result = progression.toSet().toTypedArray() this@TimeBuilder include result @@ -129,3 +128,4 @@ class HoursBuilder : TimeBuilder(hoursRange, intToByteConverter) class DaysOfMonthBuilder : TimeBuilder(dayOfMonthRange, intToByteConverter) class MonthsBuilder : TimeBuilder(monthRange, intToByteConverter) class YearsBuilder : TimeBuilder(yearRange, intToIntConverter) +class WeekDaysBuilder : TimeBuilder(dayOfWeekRange, intToByteConverter) diff --git a/src/commonMain/kotlin/dev/inmo/krontab/collection/CollectionKronScheduler.kt b/src/commonMain/kotlin/dev/inmo/krontab/collection/CollectionKronScheduler.kt index 1f552b2..97fe0c3 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/collection/CollectionKronScheduler.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/collection/CollectionKronScheduler.kt @@ -1,17 +1,16 @@ package dev.inmo.krontab.collection import com.soywiz.klock.DateTime +import com.soywiz.klock.DateTimeTz import dev.inmo.krontab.* import dev.inmo.krontab.internal.* -import dev.inmo.krontab.internal.CronDateTimeScheduler -import dev.inmo.krontab.internal.toNearDateTime /** * This scheduler will be useful in case you want to unite several different [KronScheduler]s */ data class CollectionKronScheduler internal constructor( internal val schedulers: MutableList -) : KronScheduler { +) : KronSchedulerTz { internal constructor() : this(mutableListOf()) /** @@ -38,6 +37,18 @@ data class CollectionKronScheduler internal constructor( mergeCronDateTimeSchedulers(resultCronDateTimes) ) } + is CronDateTimeSchedulerTz -> { + val newCronDateTimes = kronScheduler.cronDateTimes.toMutableList() + val cronDateTimes = schedulers.removeAll { + if (it is CronDateTimeSchedulerTz && it.offset == kronScheduler.offset) { + newCronDateTimes.addAll(it.cronDateTimes) + true + } else { + false + } + } + schedulers.add(CronDateTimeSchedulerTz(newCronDateTimes.toList(), kronScheduler.offset)) + } is CollectionKronScheduler -> kronScheduler.schedulers.forEach { include(it) } @@ -48,4 +59,8 @@ data class CollectionKronScheduler internal constructor( override suspend fun next(relatively: DateTime): DateTime { return schedulers.mapNotNull { it.next(relatively) }.minOrNull() ?: getAnyNext(relatively) } + + override suspend fun next(relatively: DateTimeTz): DateTimeTz { + return schedulers.mapNotNull { it.next(relatively) }.minOrNull() ?: getAnyNext(relatively.local).toOffsetUnadjusted(relatively.offset) + } } diff --git a/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTime.kt b/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTime.kt index 269cd9b..49cf462 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTime.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTime.kt @@ -1,10 +1,11 @@ package dev.inmo.krontab.internal -import com.soywiz.klock.DateTime -import com.soywiz.klock.DateTimeSpan +import com.soywiz.klock.* import dev.inmo.krontab.KronScheduler /** + * @param dayOfweek 0-6 + * @param year any int * @param month 0-11 * @param dayOfMonth 0-31 * @param hours 0-23 @@ -12,6 +13,7 @@ import dev.inmo.krontab.KronScheduler * @param seconds 0-59 */ internal data class CronDateTime( + val dayOfweek: Byte? = null, val year: Int? = null, val month: Byte? = null, val dayOfMonth: Byte? = null, @@ -20,6 +22,7 @@ internal data class CronDateTime( val seconds: Byte? = null ) { init { + check(dayOfweek ?.let { it in dayOfWeekRange } ?: true) check(year ?.let { it in yearRange } ?: true) check(month ?.let { it in monthRange } ?: true) check(dayOfMonth ?.let { it in dayOfMonthRange } ?: true) @@ -29,14 +32,34 @@ internal data class CronDateTime( } internal val klockDayOfMonth = dayOfMonth ?.plus(1) + internal val dayOfWeekInt: Int? = dayOfweek ?.toInt() } /** + * THIS METHOD WILL NOT TAKE CARE ABOUT [offset] PARAMETER. It was decided due to the fact that we unable to get + * real timezone offset from simple [DateTime] + * * @return The near [DateTime] which happens after [relativelyTo] or will be equal to [relativelyTo] */ internal fun CronDateTime.toNearDateTime(relativelyTo: DateTime = DateTime.now()): DateTime? { var current = relativelyTo + val weekDay = dayOfWeekInt + if (weekDay != null && current.dayOfWeek.index0 != weekDay) { + do { + var diff = weekDay - current.dayOfWeek.index0 + if (diff < 0) { + diff += 7 /* days in week */ + } + current = (current + diff.days).startOfDay + + val next = toNearDateTime(current) + if (next == null || next.dayOfWeek.index0 == weekDay) { + return next + } + } while (true) + } + seconds?.let { val left = it - current.seconds current += DateTimeSpan(minutes = if (left <= 0) 1 else 0, seconds = left) @@ -77,17 +100,15 @@ internal fun CronDateTime.toNearDateTime(relativelyTo: DateTime = DateTime.now() return current } -/** - * @return [KronScheduler] (in fact [CronDateTimeScheduler]) based on incoming data - */ -internal fun createKronScheduler( +internal fun createCronDateTimeList( seconds: Array? = null, minutes: Array? = null, hours: Array? = null, dayOfMonth: Array? = null, month: Array? = null, - years: Array? = null -): KronScheduler { + years: Array? = null, + weekDays: Array? = null +): List { val resultCronDateTimes = mutableListOf(CronDateTime()) seconds ?.fillWith(resultCronDateTimes) { previousCronDateTime: CronDateTime, currentTime: Byte -> @@ -114,5 +135,35 @@ internal fun createKronScheduler( previousCronDateTime.copy(year = currentTime) } - return CronDateTimeScheduler(resultCronDateTimes.toList()) + weekDays ?.fillWith(resultCronDateTimes) { previousCronDateTime: CronDateTime, currentTime: Byte -> + previousCronDateTime.copy(dayOfweek = currentTime) + } + + return resultCronDateTimes.toList() } + +/** + * @return [KronScheduler] (in fact [CronDateTimeScheduler]) based on incoming data + */ +internal fun createKronScheduler( + seconds: Array? = null, + minutes: Array? = null, + hours: Array? = null, + dayOfMonth: Array? = null, + month: Array? = null, + years: Array? = null, + weekDays: Array? = null +): KronScheduler = CronDateTimeScheduler(createCronDateTimeList(seconds, minutes, hours, dayOfMonth, month, years, weekDays)) +/** + * @return [KronScheduler] (in fact [CronDateTimeScheduler]) based on incoming data + */ +internal fun createKronSchedulerWithOffset( + seconds: Array? = null, + minutes: Array? = null, + hours: Array? = null, + dayOfMonth: Array? = null, + month: Array? = null, + years: Array? = null, + weekDays: Array? = null, + offset: TimezoneOffset +): KronScheduler = CronDateTimeSchedulerTz(createCronDateTimeList(seconds, minutes, hours, dayOfMonth, month, years, weekDays), offset) diff --git a/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTimeScheduler.kt b/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTimeScheduler.kt index 23de605..b4b3bcf 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTimeScheduler.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTimeScheduler.kt @@ -1,7 +1,7 @@ package dev.inmo.krontab.internal import com.soywiz.klock.DateTime -import dev.inmo.krontab.* +import dev.inmo.krontab.KronScheduler import dev.inmo.krontab.collection.plus /** @@ -26,8 +26,8 @@ internal data class CronDateTimeScheduler internal constructor( * * @see toNearDateTime */ - override suspend fun next(relatively: DateTime): DateTime { - return cronDateTimes.mapNotNull { it.toNearDateTime(relatively) }.minOrNull() ?: getAnyNext(relatively) + override suspend fun next(relatively: DateTime): DateTime? { + return cronDateTimes.mapNotNull { it.toNearDateTime(relatively) }.minOrNull() } } diff --git a/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTimeSchedulerTz.kt b/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTimeSchedulerTz.kt new file mode 100644 index 0000000..adf74d4 --- /dev/null +++ b/src/commonMain/kotlin/dev/inmo/krontab/internal/CronDateTimeSchedulerTz.kt @@ -0,0 +1,31 @@ +package dev.inmo.krontab.internal + +import com.soywiz.klock.DateTimeTz +import com.soywiz.klock.TimezoneOffset +import dev.inmo.krontab.KronScheduler +import dev.inmo.krontab.KronSchedulerTz + +/** + * Cron-oriented realisation of [KronScheduler] with taking into account [offset] for list of [cronDateTimes] + * + * @see CronDateTime + */ +internal data class CronDateTimeSchedulerTz internal constructor( + internal val cronDateTimes: List, + internal val offset: TimezoneOffset +) : KronSchedulerTz { + override suspend fun next(relatively: DateTimeTz): DateTimeTz? { + val dateTimeWithActualOffset = relatively.toOffset(offset).local + return cronDateTimes.mapNotNull { + it.toNearDateTime(dateTimeWithActualOffset) + }.minOrNull() ?.toOffsetUnadjusted(offset) ?.toOffset(relatively.offset) + } +} + +internal fun mergeCronDateTimeSchedulers( + schedulers: List +) = schedulers.groupBy { + it.offset +}.map { (offset, schedulers) -> + CronDateTimeSchedulerTz(schedulers.flatMap { it.cronDateTimes }, offset) +} diff --git a/src/commonMain/kotlin/dev/inmo/krontab/internal/Parser.kt b/src/commonMain/kotlin/dev/inmo/krontab/internal/Parser.kt index f2f8648..17c9be2 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/internal/Parser.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/internal/Parser.kt @@ -1,7 +1,5 @@ package dev.inmo.krontab.internal -import dev.inmo.krontab.utils.clamp - typealias Converter = (Int) -> T internal val intToByteConverter: Converter = { it: Int -> it.toByte() } @@ -18,7 +16,7 @@ private fun createSimpleScheduler(from: String, dataRange: IntRange, dataCon when { currentToken.contains("-") -> { val splitted = currentToken.split("-") - (splitted.first().toInt().clamp(dataRange) .. splitted[1].toInt().clamp(dataRange)).toList() + (splitted.first().toInt().coerceIn(dataRange) .. splitted[1].toInt().coerceIn(dataRange)).toList() } currentToken.contains("/") -> { val (start, step) = currentToken.split("/") @@ -26,18 +24,20 @@ private fun createSimpleScheduler(from: String, dataRange: IntRange, dataCon 0 } else { start.toInt() - }).clamp(dataRange) - val stepNum = step.toInt().clamp(dataRange) + }).coerceIn(dataRange) + val stepNum = step.toInt().coerceIn(dataRange) (startNum .. dataRange.last step stepNum).map { it } } currentToken == "*" -> return null - else -> listOf(currentToken.toInt().clamp(dataRange)) + else -> listOf(currentToken.toInt().coerceIn(dataRange)) } } return results.map(dataConverter) } +internal fun parseWeekDay(from: String?) = from ?.let { if (it.endsWith("w")) createSimpleScheduler(it.removeSuffix("w"), dayOfWeekRange, intToByteConverter) ?.toTypedArray() else null } +internal fun parseOffset(from: String?) = from ?.let { if (it.endsWith("o")) it.removeSuffix("o").toIntOrNull() else null } internal fun parseYears(from: String?) = from ?.let { createSimpleScheduler(from, yearRange, intToIntConverter) ?.toTypedArray() } internal fun parseMonths(from: String) = createSimpleScheduler(from, monthRange, intToByteConverter) ?.toTypedArray() internal fun parseDaysOfMonth(from: String) = createSimpleScheduler(from, dayOfMonthRange, intToByteConverter) ?.toTypedArray() @@ -60,3 +60,16 @@ internal fun Array.fillWith( } } +internal fun T.fillWith( + whereToPut: MutableList, + createFactory: (CronDateTime, T) -> CronDateTime +) { + val previousValues = whereToPut.toList() + + whereToPut.clear() + + previousValues.forEach { previousValue -> + whereToPut.add(createFactory(previousValue, this)) + } +} + diff --git a/src/commonMain/kotlin/dev/inmo/krontab/internal/Ranges.kt b/src/commonMain/kotlin/dev/inmo/krontab/internal/Ranges.kt index ec4359b..e0a03b7 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/internal/Ranges.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/internal/Ranges.kt @@ -1,5 +1,6 @@ package dev.inmo.krontab.internal +internal val dayOfWeekRange = 0 .. 6 internal val yearRange = Int.MIN_VALUE .. Int.MAX_VALUE internal val monthRange = 0 .. 11 internal val dayOfMonthRange = 0 .. 30 diff --git a/src/commonMain/kotlin/dev/inmo/krontab/utils/Data.kt b/src/commonMain/kotlin/dev/inmo/krontab/utils/Data.kt deleted file mode 100644 index f442496..0000000 --- a/src/commonMain/kotlin/dev/inmo/krontab/utils/Data.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.inmo.krontab.utils - -/** - * @return [min] in case if [this] less than [min]. Otherwise will check that [max] grant than [this] and return [this] - * if so or [max] otherwise - */ -internal fun Int.clamp(min: Int, max: Int): Int = if (this < min) min else if (this > max) max else this - -/** - * Wrapper function for [clamp] extension - */ -internal fun Int.clamp(range: IntRange): Int = clamp(range.first, range.last) diff --git a/src/commonMain/kotlin/dev/inmo/krontab/utils/SchedulerFlow.kt b/src/commonMain/kotlin/dev/inmo/krontab/utils/SchedulerFlow.kt index 14cc761..81738f4 100644 --- a/src/commonMain/kotlin/dev/inmo/krontab/utils/SchedulerFlow.kt +++ b/src/commonMain/kotlin/dev/inmo/krontab/utils/SchedulerFlow.kt @@ -1,14 +1,39 @@ package dev.inmo.krontab.utils import com.soywiz.klock.DateTime +import com.soywiz.klock.DateTimeTz import dev.inmo.krontab.KronScheduler -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay +import dev.inmo.krontab.next +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +/** + * This [Flow] will trigger emitting each near time which will be returned from [this] [KronScheduler] with attention to + * time zones + * + * @see channelFlow + */ @FlowPreview -fun KronScheduler.asFlow(): Flow = SchedulerFlow(this) +fun KronScheduler.asTzFlow(): Flow = channelFlow { + while (isActive) { + val now = DateTime.now().local + val nextTime = next(now) ?: break + val sleepDelay = (nextTime - DateTime.now().local).millisecondsLong + delay(sleepDelay) + send(nextTime) + } +} +/** + * This method is a map for [asTzFlow] and will works the same but return flow with [DateTime]s + */ +@FlowPreview +fun KronScheduler.asFlow(): Flow = asTzFlow().map { it.local } + +@Deprecated( + "It is not recommended to use this class in future. This functionality will be removed soon", + ReplaceWith("asFlow", "dev.inmo.krontab.utils.asFlow") +) @FlowPreview class SchedulerFlow( private val scheduler: KronScheduler diff --git a/src/commonMain/kotlin/dev/inmo/krontab/utils/Typealiases.kt b/src/commonMain/kotlin/dev/inmo/krontab/utils/Typealiases.kt new file mode 100644 index 0000000..5cb9dc9 --- /dev/null +++ b/src/commonMain/kotlin/dev/inmo/krontab/utils/Typealiases.kt @@ -0,0 +1,3 @@ +package dev.inmo.krontab.utils + +typealias Minutes = Int diff --git a/src/commonTest/kotlin/dev/inmo/krontab/utils/StringParseTest.kt b/src/commonTest/kotlin/dev/inmo/krontab/utils/StringParseTest.kt index fe37fc2..108c278 100644 --- a/src/commonTest/kotlin/dev/inmo/krontab/utils/StringParseTest.kt +++ b/src/commonTest/kotlin/dev/inmo/krontab/utils/StringParseTest.kt @@ -1,5 +1,7 @@ package dev.inmo.krontab.utils +import com.soywiz.klock.* +import dev.inmo.krontab.KronSchedulerTz import dev.inmo.krontab.buildSchedule import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect @@ -76,4 +78,19 @@ class StringParseTest { assertEquals(expectedCollects, collected) } } + @Test + fun testThatTimezoneCorrectlyDeserialized() { + val now = DateTimeTz.nowLocal() + + runTest { + for (i in 0 .. 1339) { + val expectedInCurrentOffset = now.toOffset(TimezoneOffset(i.minutes)) + 1.hours + val kronScheduler = buildSchedule( + "${expectedInCurrentOffset.seconds} ${expectedInCurrentOffset.minutes} ${expectedInCurrentOffset.hours} * * ${i}o" + ) as KronSchedulerTz + val next = kronScheduler.next(now) + assertEquals(expectedInCurrentOffset.toOffset(now.offset), next) + } + } + } } diff --git a/src/commonTest/kotlin/dev/inmo/krontab/utils/TimeZoneTest.kt b/src/commonTest/kotlin/dev/inmo/krontab/utils/TimeZoneTest.kt new file mode 100644 index 0000000..4663174 --- /dev/null +++ b/src/commonTest/kotlin/dev/inmo/krontab/utils/TimeZoneTest.kt @@ -0,0 +1,27 @@ +package dev.inmo.krontab.utils + +import com.soywiz.klock.* +import dev.inmo.krontab.builder.buildSchedule +import dev.inmo.krontab.next +import kotlin.test.Test +import kotlin.test.assertEquals + +class TimeZoneTest { + @Test + fun testDifferentTimeZonesReturnsDifferentTimes() { + val scheduler = buildSchedule { seconds { every(1) } } + val baseDate = DateTime.now().startOfWeek + runTest { + for (i in 0 until 7) { + val now = baseDate + i.days + for (j in 0 .. 24) { + val nowTz = now.toOffset(j.hours) + val next = scheduler.next(nowTz)!! + assertEquals( + (nowTz + 1.seconds).utc.unixMillisLong, next.utc.unixMillisLong + ) + } + } + } + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/dev/inmo/krontab/utils/WeekDaysTest.kt b/src/commonTest/kotlin/dev/inmo/krontab/utils/WeekDaysTest.kt new file mode 100644 index 0000000..9b8d8b7 --- /dev/null +++ b/src/commonTest/kotlin/dev/inmo/krontab/utils/WeekDaysTest.kt @@ -0,0 +1,38 @@ +package dev.inmo.krontab.utils + +import com.soywiz.klock.* +import dev.inmo.krontab.builder.buildSchedule +import kotlin.math.ceil +import kotlin.test.* + +class WeekDaysTest { + @Test + fun testThatWeekDaysSchedulingWorks() { + val startDateTime = DateTime.now().startOfDay + val weekDay = startDateTime.dayOfWeek.index0 + val testDays = 400 + val scheduler = buildSchedule { + dayOfWeek { + at(weekDay) + } + years { + at(startDateTime.yearInt) + } + } + runTest { + for (day in 0 until testDays) { + val currentDateTime = startDateTime + day.days + val next = scheduler.next(currentDateTime) + val expected = when { + day % 7 == 0 -> currentDateTime + else -> startDateTime + ceil(day.toFloat() / 7).weeks + } + if (expected.yearInt != startDateTime.yearInt) { + assertNull(next) + } else { + assertEquals(expected, next) + } + } + } + } +}