diff --git a/src/main/scala/com.github.mgifos.workouts/Main.scala b/src/main/scala/com.github.mgifos.workouts/Main.scala index 3b4e03c..ffab296 100644 --- a/src/main/scala/com.github.mgifos.workouts/Main.scala +++ b/src/main/scala/com.github.mgifos.workouts/Main.scala @@ -43,9 +43,7 @@ object Main extends App { case Some(config) => val worker = run(config).andThen { - case _ => - shutdown() - log.info("Logged out. Connection is closed.") + case _ => shutdown() } Await.result(worker, 10.minutes) log.info("Bye") @@ -104,40 +102,52 @@ object Main extends App { def run(implicit config: Config): Future[Unit] = { + implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))(config.system) + val console = System.console() - val email = if (config.email.nonEmpty) config.email else { - print("Please enter your email address to login to Garmin Connect: ") - console.readLine() - } + def proceedToGarmin() = { + val email = if (config.email.nonEmpty) config.email else { + print("Please enter your email address to login to Garmin Connect: ") + console.readLine() + } + val password = if (config.password.nonEmpty) config.password else { + print("Password: ") + new String(console.readPassword()) + } - val password = if (config.password.nonEmpty) config.password else { - print("Password: ") - new String(console.readPassword()) + implicit val garmin: GarminConnect = new GarminConnect(email, password) + val workouts = plan.workouts.toIndexedSeq + + garmin.login().flatMap { + case Right(s) => + implicit val session: GarminSession = s + for { + maybeDeleteMessage <- deleteWorkoutsTask(workouts.map(_.name)) + maybeGarminWorkouts <- createWorkoutsTask(workouts) + maybeScheduleMessage <- scheduleTask(maybeGarminWorkouts.fold(Seq.empty[GarminWorkout])(identity)) + } yield { + log.info("\nStatistics:") + maybeDeleteMessage.foreach(msg => log.info(" " + msg)) + maybeGarminWorkouts.foreach(workouts => log.info(s" ${workouts.length} imported")) + maybeScheduleMessage.foreach(msg => log.info(" " + msg)) + log.info("Logging out and closing connection...") + } + case Left(loginFailureMessage) => + log.error(loginFailureMessage) + Future.successful(()) + } } - implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))(config.system) - - implicit val garmin: GarminConnect = new GarminConnect(email, password) - - val workouts = plan.workouts.toIndexedSeq - - garmin.login().flatMap { - case Right(s) => - implicit val session: GarminSession = s - for { - maybeDeleteMessage <- deleteWorkoutsTask(workouts.map(_.name)) - maybeGarminWorkouts <- createWorkoutsTask(workouts) - maybeScheduleMessage <- scheduleTask(maybeGarminWorkouts.fold(Seq.empty[GarminWorkout])(identity)) - } yield { - log.info("\nStatistics:") - maybeDeleteMessage.foreach(msg => log.info(" " + msg)) - maybeGarminWorkouts.foreach(workouts => log.info(s" ${workouts.length} imported")) - maybeScheduleMessage.foreach(msg => log.info(" " + msg)) - } - case Left(loginFailureMessage) => - log.error(loginFailureMessage) - Future.successful(()) + if (plan.invalid().isEmpty) proceedToGarmin() + else { + plan.invalid().foreach(i => log.warn(i.toString)) + println("Your plan contains some invalid items.") + print("Do you want to proceed to Garmin by skipping these items? [Y/n]") + "" + console.readLine() match { + case "" | "y" | "Y" => proceedToGarmin() + case _ => Future.successful(()) + } } } @@ -163,12 +173,12 @@ object Main extends App { if (config.mode.contains(Modes.schedule)) { val start = (config.start, config.end) match { - case (_, end) if !end.isEqual(LocalDate.MIN) => end.minusDays(plan.get.length - 1) + case (_, end) if !end.isEqual(LocalDate.MIN) => end.minusDays(plan.get().length - 1) case (from, _) => from } val woMap: Map[String, GarminWorkout] = Map(workouts.map(ga => ga.name -> ga): _*) - val spec = plan.get.zipWithIndex.collect { + val spec = plan.get().zipWithIndex.collect { case (Some(ref), day) if !start.plusDays(day).isBefore(LocalDate.now()) => start.plusDays(day) -> woMap(ref.name) }.to[Seq] garmin.schedule(spec).map(c => Some(s"$c scheduled")) diff --git a/src/main/scala/com.github.mgifos.workouts/model/Step.scala b/src/main/scala/com.github.mgifos.workouts/model/Step.scala index 883209d..9f06545 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Step.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Step.scala @@ -59,7 +59,7 @@ object Step { def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Step = x match { case StepRx(header, subSteps, _) if subSteps.nonEmpty => header match { case StepHeader(name, params) => - assert(name == "repeat", "must be 'repeat' if contains sub-steps") + if (name != "repeat") throw new IllegalArgumentException(s"'$name' cannot contain sub-steps, it must be 'repeat'") RepeatStep(params.trim.toInt, subSteps.trim.lines.toList.map(parseDurationStep)) case _ => throw new IllegalArgumentException(s"Cannot parse repeat step $header") } @@ -73,16 +73,16 @@ object Step { case "run" | "bike" | "go" => IntervalStep.tupled(expect(params)) case "recover" => RecoverStep.tupled(expect(params)) case "cooldown" => CooldownStep.tupled(expect(params)) - case _ => throw new IllegalArgumentException(s"Duration step type was expected, $name") + case _ => throw new IllegalArgumentException(s"'$name' is not a duration step type") } - case _ => throw new IllegalArgumentException(s"Cannot parse step type $x") + case _ => throw new IllegalArgumentException(s"Cannot parse duration step: $x") } - private def expect(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): (Duration, Option[Target]) = x match { + private def expect(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): (Duration, Option[Target]) = x.trim match { case ParamsRx(duration, _, target) => val maybeTarget = Option(target).filter(_.trim.nonEmpty).map(Target.parse) (Duration.parse(duration.trim), maybeTarget) - case _ => throw new IllegalArgumentException(s"Cannot parse step parameters $x") + case raw => throw new IllegalArgumentException(s"Cannot parse step parameters $raw") } } diff --git a/src/main/scala/com.github.mgifos.workouts/model/Target.scala b/src/main/scala/com.github.mgifos.workouts/model/Target.scala index 34f04d2..3668f40 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Target.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Target.scala @@ -89,6 +89,6 @@ object Target { case PaceRangeRx(from, to, uom) => val du = Option(uom).fold(msys.distance)(DistanceUnits.withPaceUOM) PaceTarget(Pace(du, from), Pace(du, to)) - case _ => throw new IllegalArgumentException(s"Unknown target specification: $x") + case raw => throw new IllegalArgumentException(s"'$raw' is not a valid target specification") } } \ No newline at end of file diff --git a/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala index 682b5a2..5e8828e 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala @@ -13,9 +13,9 @@ class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.Measurement def weekPlan(week: Week, previousWeeks: Seq[Option[Workout]]): Seq[Option[Workout]] = Seq.tabulate(7) { weekDayNo => week.lift(weekDayNo + 1).flatMap(text => Option(text.trim).filter(_.nonEmpty)) }.foldLeft(Seq.empty[Option[Workout]])((acc, maybeDayText) => acc :+ maybeDayText.map { dayText => - Workout.parseDef(dayText) match { - case Right(definition) => definition - case Left(_) => onlyDefs(previousWeeks ++ acc).find(_.name == dayText).map(_.toRef).getOrElse(WorkoutNote(dayText)) + Workout.parse(dayText) match { + case note: WorkoutNote => onlyDefs(previousWeeks ++ acc).find(_.name == dayText).map(_.toRef).getOrElse(note) + case w: Workout => w } }) @@ -35,12 +35,16 @@ class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.Measurement /** * @return optional workout refs (defs included as refs) */ - def get: Seq[Option[WorkoutRef]] = processed.map { + def get(): Seq[Option[WorkoutRef]] = processed.map { case Some(x: WorkoutDef) => Some(x.toRef) case Some(x: WorkoutRef) => Some(x) case _ => None } + def invalid(): Seq[Workout] = processed.collect { + case Some(x) if !x.valid() => x + } + private def isAValidWeek(w: Seq[String]) = w.headOption.exists(no => no.trim.nonEmpty && no.forall(_.isDigit)) private def onlyDefs(days: Seq[Option[Workout]]) = days.flatMap { diff --git a/src/main/scala/com.github.mgifos.workouts/model/Workout.scala b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala index 86f2104..274d119 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Workout.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala @@ -4,13 +4,14 @@ import play.api.libs.json.{ JsValue, Json } import Workout._ trait Workout { - def json: JsValue + def json(): JsValue = Json.obj() + def valid(): Boolean = true } case class WorkoutDef(sport: String, name: String, steps: Seq[Step] = Nil) extends Workout { def toRef: WorkoutRef = WorkoutRef(name) def withStep(step: Step): WorkoutDef = WorkoutDef(sport, name, steps :+ step) - def json: JsValue = Json.obj( + override def json(): JsValue = Json.obj( "sportType" -> Json.obj( "sportTypeId" -> sportId(sport), "sportTypeKey" -> sportTypeKey(sport)), @@ -24,38 +25,46 @@ case class WorkoutDef(sport: String, name: String, steps: Seq[Step] = Nil) exten "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) }))) } -case class WorkoutRef(name: String) extends Workout { - def json: JsValue = Json.obj() +case class WorkoutDefFailure(`type`: String, original: String, cause: String) extends Workout { + override def toString = s"""Possible workout definition that can't be parsed: "$original"\nCause: "$cause"\n-------------------------------------""" + override def valid(): Boolean = false } -case class WorkoutNote(note: String) extends Workout { - def json: JsValue = Json.obj() +case class WorkoutStepFailure(original: String, cause: String) extends Workout { + override def toString = s"""Workout steps that can't be parsed: "$original"\nCause: "$cause"\n-------------------------------------""" + override def valid(): Boolean = false } +case class WorkoutRef(name: String) extends Workout + +case class WorkoutNote(note: String) extends Workout + object Workout { - private val WorkoutHeader = """^(running|cycling|custom):\s([\u0020-\u007F]+)(([\r\n]+\s*\-\s[a-z]+:.*)*)$""".r + private val WorkoutType = "(running|cycling|custom)" + private val WorkoutHeader = raw"""^$WorkoutType:\s([\u0020-\u007F]+)(([\r\n]+\s*\-\s[a-z]+:.*)*)$$""".r private val NextStepRx = """^((-\s\w*:\s.*)(([\r\n]+\s{1,}-\s.*)*))(([\s].*)*)$""".r + private val PossibleWorkoutHeader = raw"""^\s*$WorkoutType\s*:\s*.*(([\r\n]+\s*.*)*)$$""".r - def parseDef(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Either[String, WorkoutDef] = { - def loop(w: WorkoutDef, steps: String): Either[String, WorkoutDef] = steps match { + def parse(text: String)(implicit msys: MeasurementSystems.MeasurementSystem): Workout = { + def loop(w: WorkoutDef, steps: String): Workout = steps match { case NextStepRx(next, _, _, _, rest, _) => - val newWorkout = w.withStep(Step.parse(next.trim)) - if (rest.trim.isEmpty) Right(newWorkout) - else loop(newWorkout, rest.trim) - case _ => Left(s"Input string cannot be parsed to Workout: $steps") + try { + val newWorkout = w.withStep(Step.parse(next.trim)) + if (rest.trim.isEmpty) newWorkout + else loop(newWorkout, rest.trim) + } catch { + case ex: IllegalArgumentException => WorkoutStepFailure(text, ex.getMessage.trim) + } + case _ => WorkoutStepFailure(text, steps.trim) } - x match { + text match { case WorkoutHeader(sport, name, steps, _) => loop(WorkoutDef(sport, name), steps.trim) - case _ => Left(s"Input string cannot be parsed to Workout: $x") + case PossibleWorkoutHeader(t, _, cause) => WorkoutDefFailure(`type` = t, text, if (cause == null) "" else cause.trim) + case _ => WorkoutNote(text) } } - def parseRef(x: String): WorkoutRef = x match { - case WorkoutHeader(_, name, _, _) => WorkoutRef(name) - case _ => WorkoutRef(x.trim) - } - def sportId(sport: String) = sport match { case "running" => 1 case "cycling" => 2 diff --git a/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala index 5cce82e..078daea 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala @@ -10,7 +10,7 @@ class StepSpec extends FlatSpec with Matchers { "Step" should "parse correctly" in { a[IllegalArgumentException] should be thrownBy Step.parse("") - a[AssertionError] should be thrownBy Step.parse("- warmup: 5km\n - run: 10km\n - recover: 100m") + a[IllegalArgumentException] should be thrownBy Step.parse("- warmup: 5km\n - run: 10km\n - recover: 100m") Step.parse("- warmup: 5km") should be(WarmupStep(DistanceDuration(5, km))) Step.parse("- run: 2km @ 5:00-4:50") should be(IntervalStep(DistanceDuration(2, km), Some(PaceTarget(Pace(msys.distance, "5:00"), Pace(msys.distance, "4:50"))))) diff --git a/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala index 8155b37..86c9c57 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala @@ -1,10 +1,10 @@ package com.github.mgifos.workouts.model import com.github.mgifos.workouts.model.DistanceUnits._ -import org.scalatest.{ FlatSpec, Matchers } +import org.scalatest.{ Matchers, WordSpec } import play.api.libs.json.Json -class WorkoutSpec extends FlatSpec with Matchers { +class WorkoutSpec extends WordSpec with Matchers { implicit val msys = MeasurementSystems.metric @@ -18,47 +18,71 @@ class WorkoutSpec extends FlatSpec with Matchers { */ val testWO = "running: run-fast\n- warmup: 10:00\n- repeat: 2\n - run: 1500m @ 4:30-5:00\n - recover: 01:30 @ z2\n- cooldown: lap-button" - "Workout" should "parse correctly" in { - Workout.parseDef("") should be('left) - Workout.parseDef("running") should be('left) - Workout.parseDef("running:") should be('left) - Workout.parseDef("running !") should be('left) - Workout.parseDef("running run-fast") should be('left) - Workout.parseDef(" running: run-fast") should be('left) + "Workout parser should" should { - Workout.parseDef(testWO) should be( - Right( - WorkoutDef("running", "run-fast", Seq( - WarmupStep(TimeDuration(minutes = 10)), - RepeatStep(2, Seq( - IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace(msys.distance, "4:30"), Pace(msys.distance, "5:00")))), - RecoverStep(TimeDuration(1, 30), Some(HrZoneTarget(2))))), - CooldownStep(LapButtonPressed))))) - } + "parse notes correctly" in { + Workout.parse("") shouldBe a[WorkoutNote] + Workout.parse("running") shouldBe a[WorkoutNote] + Workout.parse("running !") shouldBe a[WorkoutNote] + Workout.parse("running run-fast") shouldBe a[WorkoutNote] + } + + "parse a workout definition correctly" in { + Workout.parse(testWO) shouldBe WorkoutDef("running", "run-fast", Seq( + WarmupStep(TimeDuration(minutes = 10)), + RepeatStep(2, Seq( + IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace(msys.distance, "4:30"), Pace(msys.distance, "5:00")))), + RecoverStep(TimeDuration(1, 30), Some(HrZoneTarget(2))))), + CooldownStep(LapButtonPressed))) + } - "Workout" should "parse various printable workout-names correctly" in { + "parse various printable workout-names correctly" in { - val testNames = Seq("abcz", "123 xyw", """abc!/+-@,?*;:_!\"#$%&/()=?*""") + val testNames = Seq("abcz", "123 xyw", """abc!/+-@,?*;:_!\"#$%&/()=?*""") - testNames.foreach { testName => - val x = Workout.parseDef(testWO.replace("run-fast", testName)) - x.right.get.name should be(testName) + testNames.foreach { testName => + val x = Workout.parse(testWO.replace("run-fast", testName)) + x shouldBe a[WorkoutDef] + x.asInstanceOf[WorkoutDef].name should be(testName) + } + } + + "parse cycling workouts" in { + val testBike = "cycling: cycle-test\r\n- warmup: 5:00\n- bike: 20km @ 20.0-100kph\r- cooldown: lap-button" + Workout.parse(testBike) shouldBe WorkoutDef("cycling", "cycle-test", Seq( + WarmupStep(TimeDuration(minutes = 5)), + IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(Speed(km, "20.0"), Speed(km, "100")))), + CooldownStep(LapButtonPressed))) + } + + "validate on a workout definition level" in { + Workout.parse("running:") shouldBe a[WorkoutDefFailure] + Workout.parse("running: run-fast\nhm") shouldBe a[WorkoutDefFailure] + Workout.parse("running: run\n- warmup: 10:00") shouldBe a[WorkoutDefFailure] } - } - "Workout" should "dump json correctly" in { - val is = getClass.getClassLoader.getResourceAsStream("run-fast.json") - val expectJson = Json.parse(is) - Workout.parseDef(testWO).map(_.json) should be(Right(expectJson)) + "validate on a step level" in { + Workout.parse("running: run\n- run: 5km 2+2") should matchPattern { + case WorkoutStepFailure(_, "Cannot parse step parameters 5km 2+2") => + } + Workout.parse("running: run\n- run: 10:00\n - run: 1500m @ 4:30-5:00") should matchPattern { + case WorkoutStepFailure(_, "'run' cannot contain sub-steps, it must be 'repeat'") => + } + Workout.parse("running: run\n- and: fail at this step") should matchPattern { + case WorkoutStepFailure(_, "'and' is not a duration step type") => + } + Workout.parse("running: run\n- run: 10km @ 20x") should matchPattern { + case WorkoutStepFailure(_, "'20x' is not a valid target specification") => + } + } } - "Workout" should "support cycling ws" in { - val testBike = "cycling: cycle-test\r\n- warmup: 5:00\n- bike: 20km @ 20.0-100kph\r- cooldown: lap-button" - Workout.parseDef(testBike) should be( - Right( - WorkoutDef("cycling", "cycle-test", Seq( - WarmupStep(TimeDuration(minutes = 5)), - IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(Speed(km, "20.0"), Speed(km, "100")))), - CooldownStep(LapButtonPressed))))) + "Workout should" should { + "dump json correctly" in { + val is = getClass.getClassLoader.getResourceAsStream("run-fast.json") + val expectJson = Json.parse(is) + Workout.parse(testWO).json() should be(expectJson) + } } + }