diff --git a/.scalafix.conf b/.scalafix.conf index 929b82e..904a475 100644 --- a/.scalafix.conf +++ b/.scalafix.conf @@ -1,5 +1,6 @@ OrganizeImports { coalesceToWildcardImportThreshold = 5 groupedImports = Merge - removeUnused=false + removeUnused=true } +OrganizeImports.targetDialect = Scala3 diff --git a/Config.scala b/Config.scala index df1ae31..99ff399 100644 --- a/Config.scala +++ b/Config.scala @@ -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 @@ -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 @@ -32,7 +38,7 @@ case class Config( notification: NotifyConfig, cronitor: Option[ApiToken], redis: Option[RedisConfig], - server: Option[ServerConfig], + server: ServerConfig, log: LogLevel ) extends BaseConfig @@ -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 = @@ -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 diff --git a/Duration.scala b/Duration.scala index 52df2e9..a754b5a 100644 --- a/Duration.scala +++ b/Duration.scala @@ -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' diff --git a/Logger.scala b/Logger.scala index a7f1f11..b3d9bf1 100644 --- a/Logger.scala +++ b/Logger.scala @@ -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) ) diff --git a/Main.scala b/Main.scala index 12df3ab..404f335 100644 --- a/Main.scala +++ b/Main.scala @@ -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} @@ -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 */ @@ -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 diff --git a/README.md b/README.md index 73c4d65..699937c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). @@ -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 ` (e.g., `--interval 5m` for every 5 minutes). +- CRON Schedules: `--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. diff --git a/Show.scala b/Show.scala index 02f3a92..c517afe 100644 --- a/Show.scala +++ b/Show.scala @@ -8,5 +8,3 @@ import scala.concurrent.duration.FiniteDuration given Show[Uri] = Show.fromToString given Show[FiniteDuration] = _.toHumanReadable - -given Show[Throwable] = Show.fromToString diff --git a/cache/RedisCacheService.scala b/cache/RedisCacheService.scala index 0d5bfe9..5126a66 100644 --- a/cache/RedisCacheService.scala +++ b/cache/RedisCacheService.scala @@ -23,7 +23,7 @@ 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] = @@ -31,7 +31,7 @@ class RedisCacheService(client: RedisConnection[IO])(using log: Logger[IO]) exte .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") diff --git a/project.scala b/project.scala index 99cc41a..391d05e 100644 --- a/project.scala +++ b/project.scala @@ -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