Skip to content

Commit

Permalink
feat: add support for CRON schedules (#85)
Browse files Browse the repository at this point in the history
Adds support for CRON schedules in addition to time intervals.

feat: allow combining multiple intervals and CRON schedules

Multiple `--interval` and `--cron` options can be combined for more flexibility.
BREAKING CHANGE: The `--server` flag is deprecated. Use `--interval` or `--cron` options instead. When not used with `--interval` or `--cron`, it will use a default of 5 minutes.
  • Loading branch information
hugo-vrijswijk authored Aug 28, 2024
1 parent 513554a commit 522275f
Show file tree
Hide file tree
Showing 9 changed files with 109 additions and 34 deletions.
3 changes: 2 additions & 1 deletion .scalafix.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
OrganizeImports {
coalesceToWildcardImportThreshold = 5
groupedImports = Merge
removeUnused=false
removeUnused=true
}
OrganizeImports.targetDialect = Scala3
34 changes: 25 additions & 9 deletions Config.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package tgtg

import cats.data.NonEmptyList
import cats.syntax.all.*
import com.comcast.ip4s.{Host, *}
import com.monovore.decline.{Argument, Opts}
import com.monovore.decline.{Argument, Opts, Visibility}
import cron4s.{Cron, CronExpr}
import org.legogroup.woof.LogLevel
import sttp.model.Uri
import tgtg.notification.NotifyConfig
Expand All @@ -17,7 +19,11 @@ type ApiToken = ApiToken.Type

case class TgtgConfig(refreshToken: ApiToken, userId: UserId)
case class RedisConfig(host: Host)
case class ServerConfig(interval: FiniteDuration)
case class ServerConfig(
intervals: Option[NonEmptyList[FiniteDuration]],
crons: Option[NonEmptyList[CronExpr]],
isServer: Boolean
)

object Email extends NewType[String]
type Email = Email.Type
Expand All @@ -32,7 +38,7 @@ case class Config(
notification: NotifyConfig,
cronitor: Option[ApiToken],
redis: Option[RedisConfig],
server: Option[ServerConfig],
server: ServerConfig,
log: LogLevel
) extends BaseConfig

Expand Down Expand Up @@ -69,6 +75,13 @@ object Config:
private given Argument[Email] = Argument.from("email")(Email(_).validNel)
private given Argument[UserId] = Argument.from("user_id")(UserId(_).validNel)

private given Argument[CronExpr] = Argument.from("cron")(c =>
Cron
.parse(c)
.leftMap(e => show"Invalid cron '$c': $e")
.toValidatedNel
)

private val refreshHelp =
"Refresh token for TooGoodToGo. Get it using the `tgtg auth` command."
private val refreshToken =
Expand Down Expand Up @@ -130,13 +143,16 @@ object Config:
Opts.env[LogLevel]("LOG_LEVEL", "Set the log level (debug, info, warn, error).", "log_level")
).withDefault(LogLevel.Info)

private val isServer = Opts.flag("server", "Run as a server (don't exit after the first run).", "s")
private val interval =
Opts
.option[FiniteDuration]("interval", "Time interval between checks for available boxes.", "i")
.withDefault(5.minutes)
private val intervals =
Opts.options[FiniteDuration]("interval", "Time interval between checks for available boxes.", "i").orNone

private val crons =
Opts.options[CronExpr]("cron", "Cron expression for when to check for available boxes.", "c").orNone

private val isServer =
Opts.flag("server", "DEPRECATED. Use --interval or --cron options", "s", Visibility.Partial).orFalse

val server = (isServer, interval).mapN((_, interval) => ServerConfig(interval)).orNone
val server = (intervals, crons, isServer).mapN(ServerConfig.apply)

val userEmail = Opts.option[Email]("user-email", "Email to use for authentication (optional).").orNone
end allOpts
Expand Down
9 changes: 5 additions & 4 deletions Duration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import scala.concurrent.duration.FiniteDuration

extension (duration: FiniteDuration)
final def toHumanReadable: String =
val units = Seq(TimeUnit.DAYS, TimeUnit.HOURS, TimeUnit.MINUTES, TimeUnit.SECONDS, TimeUnit.MILLISECONDS)
val units = Seq(TimeUnit.DAYS, TimeUnit.HOURS, TimeUnit.MINUTES, TimeUnit.SECONDS)

val secondsRounded = (duration.toMillis.toFloat / 1000).round.toLong
val timeStrings = units
.foldLeft((Chain.empty[String], duration.toMillis)):
.foldLeft((Chain.empty[String], secondsRounded)):
case ((humanReadable, rest), unit) =>
val name = unit.toString().toLowerCase()
val result = unit.convert(rest, TimeUnit.MILLISECONDS)
val diff = rest - TimeUnit.MILLISECONDS.convert(result, unit)
val result = unit.convert(rest, TimeUnit.SECONDS)
val diff = rest - TimeUnit.SECONDS.convert(result, unit)
val str = result match
case 0 => humanReadable
case 1 => humanReadable :+ show"1 ${name.init}" // Drop last 's'
Expand Down
2 changes: 1 addition & 1 deletion Logger.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ extension [T](fa: IO[T])
fa.handleErrorWith(t =>
debugLogStacktrace(t) *>
Logger[IO].error(t.getMessage()) *>
IO.whenA(t.getCause() != null)(Logger[IO].error(show"Caused by: ${t.getCause()}"))
IO.whenA(t.getCause() != null)(Logger[IO].error(show"Caused by: ${t.getCause().toString()}"))
.as(ExitCode.Error)
)

Expand Down
63 changes: 53 additions & 10 deletions Main.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package tgtg

import cats.data.{Ior, NonEmptyList}
import cats.effect.{ExitCode, IO}
import cats.syntax.all.*
import com.monovore.decline.Opts
import com.monovore.decline.effect.CommandIOApp
import cron4s.CronExpr
import eu.timepit.fs2cron.cron4s.Cron4sScheduler
import fs2.Stream
import org.legogroup.woof.{Logger, given}
import tgtg.cache.CacheKey
import tgtg.notification.{Message, Title}
Expand Down Expand Up @@ -38,24 +42,60 @@ object Main extends CommandIOApp("tgtg", "TooGoodToGo notifier for your favourit

case config: Config =>
val main = new Main(config)
config.server
.fold(main.run)(main.loop(_).guarantee(log.info("Shutting down") *> log.info("Bye!")))
.as(ExitCode.Success)
.handleErrorWithLog

main.logDeprecations
*> main.runOrServer
.as(ExitCode.Success)
.handleErrorWithLog
end match
}
end Main

final class Main(config: Config)(using log: Logger[IO]):

def runOrServer =
config.server match
// isServer is deprecated, but used: run with default interval
case ServerConfig(None, None, true) =>
loop(NonEmptyList.of(5.minutes.asLeft))
case ServerConfig(intervals, crons, _) =>
// If intervals or crons is defined, run with them. Otherwise, run once
Ior
.fromOptions(intervals, crons)
.map: ior =>
val nel = ior.bimap(_.map(_.asLeft), _.map(_.asRight)).merge

loop(nel)
.getOrElse(run)

/** Run the main loop (log errors, never exit)
*/
def loop(server: ServerConfig): IO[Unit] = fs2.Stream
.repeatEval(run.handleErrorWithLog)
.evalTap(_ => log.info(show"Sleeping for ${server.interval}"))
.meteredStartImmediately(server.interval)
.compile
.drain
def loop(intervals: NonEmptyList[Either[FiniteDuration, CronExpr]]): IO[Unit] =
val scheduler = Cron4sScheduler.systemDefault[IO]

// Run, and find the minimum next interval and log it
val runAndLog: IO[Unit] = run.handleErrorWithLog.void >> (
intervals
.parTraverse(_.fold(_.pure[IO], scheduler.fromNowUntilNext))
.map(_.minimum)
.flatMap(nextInterval => log.info(show"Sleeping for $nextInterval"))
)

val streams: List[Stream[IO, Unit]] = intervals
.map(
_.fold(
Stream.awakeEvery[IO](_),
scheduler.awakeEvery(_)
) >> Stream.eval(runAndLog)
)
.toList

// Run for the first time, then run every interval
(log.info("Starting tgtg notifier") *>
log.info(show"Intervals: ${intervals.map(_.fold(_.show, _.show)).mkString_(", ")}") *>
runAndLog *> Stream.emits(streams).parJoinUnbounded.compile.drain)
.guarantee(log.info("Shutting down") *> log.info("Bye!"))
end loop

/** Run once
*/
Expand Down Expand Up @@ -93,4 +133,7 @@ final class Main(config: Config)(using log: Logger[IO]):
)
end if

def logDeprecations = IO.whenA(config.server.isServer)(
log.warn("The --server flag is deprecated. Use --interval or --cron options instead.")
)
end Main
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The TooGoodToGo Notifier is a simple yet powerful application designed to keep y
### Key Features

- Instant notifications when your favorite TooGoodToGo stores have boxes available.
- Flexibility to run the application as a one-time check or continuously in server mode with a customizable interval.
- Flexibility to run as a one-time check or continuously with customizable intervals or CRON schedules.
- Option to utilize Redis for data storage, enabling stateless application runs.

## Getting Started
Expand Down Expand Up @@ -46,7 +46,7 @@ $ docker run ghcr.io/hugo-vrijswijk/tgtg:latest
The application operates in two modes:

1. **One-shot Mode**: Checks for boxes once and then exits.
2. **Server Mode**: Continuously monitors for boxes at a customizable interval (default: 5 minutes) without exiting.
2. **Server Mode**: Continuously monitors for boxes at [configured schedules](#schedules) without exiting.

To run the application, provide your TooGoodToGo user ID and refresh token as environment variables (`TGTG_USER_ID` and `TGTG_REFRESH_TOKEN`) or as arguments (`--user-id` and `--refresh-token`) and a [notification provider](#notifications).

Expand All @@ -68,6 +68,21 @@ Choose your preferred notification platform for available box alerts:

Each of these options can also be set as environment variables (in `SCREAMING_SNAKE_CASE`).

### Schedules

To enable server mode, specify intervals or CRON schedules for checking TooGoodToGo boxes:

- Intervals: `--interval <duration>` (e.g., `--interval 5m` for every 5 minutes).
- CRON Schedules: `--cron <cron>` (e.g., `--cron "0 0 0 * * ?"` for every day at midnight).

`tgtg` will run immediately, and then at the specified intervals or CRON schedules.

Intervals and CRON schedules can be combined as many times as needed. For example:

```bash
$ tgtg --interval 5m --cron "0 0 0 * * ?" --cron "0 0 12 * * ?"
```

### Utilizing Redis for Application State (advanced)

By default, `tgtg` stores authentication tokens and notification history cache in a local file named `cache.json`. For a stateless application, Redis can be used to manage this state.
Expand Down
2 changes: 0 additions & 2 deletions Show.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,3 @@ import scala.concurrent.duration.FiniteDuration
given Show[Uri] = Show.fromToString

given Show[FiniteDuration] = _.toHumanReadable

given Show[Throwable] = Show.fromToString
4 changes: 2 additions & 2 deletions cache/RedisCacheService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ class RedisCacheService(client: RedisConnection[IO])(using log: Logger[IO]) exte
.get[RedisF](key)
.run(client)
.flatMap(_.traverse(t => IO.fromEither(decode[T](t))))
.handleErrorWith(e => log.error(show"Failed to get key '$key': $e").as(none))
.handleErrorWith(e => log.error(show"Failed to get key '$key': ${e.getMessage()}").as(none))
.logTimed(show"getting '$key'")

def set[T: Encoder](value: T, key: CacheKey, ttl: FiniteDuration): IO[Unit] =
RedisCommands
.set[RedisF](key, value.asJson.noSpaces, SetOpts.default.copy(setSeconds = ttl.toSeconds.some))
.run(client)
.redeemWith(
e => log.error(show"Failed to set key '$key': $e").void,
e => log.error(show"Failed to set key '$key': ${e.getMessage()}").void,
_ => IO.unit
)
.logTimed(show"setting '$key' with ttl of $ttl")
Expand Down
7 changes: 4 additions & 3 deletions project.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
//> using scala "3"
//> using dep "org.typelevel::cats-effect::3.5.4"
//> using dep co.fs2::fs2-core::3.11.0
//> using dep co.fs2::fs2-io::3.11.0
//> using dep "co.fs2::fs2-core::3.11.0"
//> using dep "co.fs2::fs2-io::3.11.0"
//> using dep "com.softwaremill.sttp.client4::circe::4.0.0-M16"
//> using dep "com.softwaremill.sttp.client4::cats::4.0.0-M16"
//> using dep "io.chrisdavenport::rediculous::0.5.1"
//> using dep "io.circe::circe-core::0.14.9"
//> using dep "io.circe::circe-parser::0.14.9"
//> using dep "org.legogroup::woof-core::0.7.0"
//> using dep com.monovore::decline-effect::2.4.1
//> using dep "com.monovore::decline-effect::2.4.1"
//> using dep "eu.timepit::fs2-cron-cron4s:0.9.0"

//> using jsModuleKind "es"
//> using jsAvoidClasses false
Expand Down

0 comments on commit 522275f

Please sign in to comment.