diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..de48b93 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,3 @@ +maxColumn = 150 +includeCurlyBraceInSelectChains = false +optIn.breakChainOnFirstMethodDot = false diff --git a/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala index 0ecbb9e..ff7cdda 100644 --- a/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala +++ b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala @@ -2,7 +2,7 @@ package com.github.mgifos.workouts import java.time.LocalDate -import akka.actor.{ Actor, ActorRef, ActorSystem, Props } +import akka.actor.{Actor, ActorRef, ActorSystem, Props} import akka.http.scaladsl.Http import akka.http.scaladsl.client.RequestBuilding._ import akka.http.scaladsl.model.ContentTypes._ @@ -12,17 +12,17 @@ import akka.http.scaladsl.model.Uri.Query import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers._ import akka.pattern.ask -import akka.stream.scaladsl.{ Flow, Sink, Source } -import akka.stream.{ Materializer, ThrottleMode } +import akka.stream.scaladsl.{Flow, Sink, Source} +import akka.stream.{Materializer, ThrottleMode} import akka.util.Timeout import com.github.mgifos.workouts.GarminConnect._ import com.github.mgifos.workouts.model.WorkoutDef import com.typesafe.scalalogging.Logger -import play.api.libs.json.{ JsObject, Json } +import play.api.libs.json.{JsObject, Json} -import scala.collection.immutable.{ Map, Seq } +import scala.collection.immutable.{Map, Seq} import scala.concurrent.duration._ -import scala.concurrent.{ ExecutionContext, Future } +import scala.concurrent.{ExecutionContext, Future} import scala.language.implicitConversions import scala.util.Failure @@ -37,47 +37,46 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste private val log = Logger(getClass) /** - * Creates workout definitions - * - * @param workouts - * @return - */ + * Creates workout definitions + * + * @param workouts + * @return + */ def createWorkouts(workouts: Seq[WorkoutDef])(implicit session: GarminSession): Future[Seq[GarminWorkout]] = { log.info("\nCreating workouts:") val source = Source(workouts.map { workout => val req = Post("https://connect.garmin.com/modern/proxy/workout-service/workout") .withEntity(HttpEntity(`application/json`, workout.json.toString())) - .withHeaders(session.headers - :+ Referer("https://connect.garmin.com/modern/workout/create/running") - :+ RawHeader("NK", "NT")) + .withHeaders( + session.headers + :+ Referer("https://connect.garmin.com/modern/workout/create/running") + :+ RawHeader("NK", "NT")) workout.name -> req }) - val flow = Flow[(String, HttpRequest)] - .throttle(1, 1.second, 1, ThrottleMode.shaping) - .mapAsync(1) { - case (workout, req) => - Http().singleRequest(req).flatMap { res => - if (res.status == OK) { - res.body.map { json => - log.info(s" $workout") - GarminWorkout(workout, Json.parse(json).\("workoutId").as[Long]) - } - } else { - log.debug(s"Creation wo response: $res") - Future.failed(new Error("Cannot create workout")) + val flow = Flow[(String, HttpRequest)].throttle(1, 1.second, 1, ThrottleMode.shaping).mapAsync(1) { + case (workout, req) => + Http().singleRequest(req).flatMap { res => + if (res.status == OK) { + res.body.map { json => + log.info(s" $workout") + GarminWorkout(workout, Json.parse(json).\("workoutId").as[Long]) } + } else { + log.debug(s"Creation wo response: $res") + Future.failed(new Error("Cannot create workout")) } - } + } + } source.via(flow).runWith(Sink.seq) } /** - * Deletes workouts with provided names - * - * @param workouts Workout names - * @return Count of deleted items - */ + * Deletes workouts with provided names + * + * @param workouts Workout names + * @return Count of deleted items + */ def deleteWorkouts(workouts: Seq[String])(implicit session: GarminSession): Future[Int] = { val futureRequests = getWorkoutsMap().map { wsMap => @@ -88,23 +87,23 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste if ids.nonEmpty } yield { val label = s"$workout -> ${ids.mkString("[", ", ", "]")}" - label -> ids.map(id => Post(s"https://connect.garmin.com/modern/proxy/workout-service/workout/$id") - .withHeaders(session.headers - :+ Referer("https://connect.garmin.com/modern/workouts") - :+ RawHeader("NK", "NT") - :+ RawHeader("X-HTTP-Method-Override", "DELETE"))) + label -> ids.map( + id => + Post(s"https://connect.garmin.com/modern/proxy/workout-service/workout/$id").withHeaders( + session.headers + :+ Referer("https://connect.garmin.com/modern/workouts") + :+ RawHeader("NK", "NT") + :+ RawHeader("X-HTTP-Method-Override", "DELETE"))) } } - val source = Source.fromFuture(futureRequests).flatMapConcat(Source(_)) - .throttle(1, 1.second, 1, ThrottleMode.shaping) - .mapAsync(1) { - case (label, reqs) => - val statusesFut = Future.sequence(reqs.map(req => Http().singleRequest(req).withoutBody)) - statusesFut.map { statuses => - if (statuses.forall(_.status == NoContent)) log.info(s" $label") - else log.error(s" Cannot delete workout: $label") - } - } + val source = Source.fromFuture(futureRequests).flatMapConcat(Source(_)).throttle(1, 1.second, 1, ThrottleMode.shaping).mapAsync(1) { + case (label, reqs) => + val statusesFut = Future.sequence(reqs.map(req => Http().singleRequest(req).withoutBody)) + statusesFut.map { statuses => + if (statuses.forall(_.status == NoContent)) log.info(s" $label") + else log.error(s" Cannot delete workout: $label") + } + } source.runWith(Sink.seq).map(_.length) } @@ -127,34 +126,38 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste else log.error(s" Cannot schedule: $label") } } - .runWith(Sink.seq).map(_.length) + .runWith(Sink.seq) + .map(_.length) } /** - * Retrieves workout mapping: name -> Seq[id] @ GarminConnect - * @return - */ + * Retrieves workout mapping: name -> Seq[id] @ GarminConnect + * @return + */ private def getWorkoutsMap()(implicit session: GarminSession): Future[Map[String, Seq[Long]]] = { val req = Get("https://connect.garmin.com/modern/proxy/workout-service/workouts?start=1&limit=9999&myWorkoutsOnly=true&sharedWorkoutsOnly=false") - .withHeaders(session.headers - :+ Referer("https://connect.garmin.com/modern/workouts") - :+ RawHeader("NK", "NT")) - val source = Source.fromFuture( - Http().singleRequest(req).flatMap { res => - if (res.status == OK) - res.body.map { json => - Json.parse(json).asOpt[Seq[JsObject]].map { arr => + .withHeaders( + session.headers + :+ Referer("https://connect.garmin.com/modern/workouts") + :+ RawHeader("NK", "NT")) + val source = Source.fromFuture(Http().singleRequest(req).flatMap { res => + if (res.status == OK) + res.body.map { json => + Json + .parse(json) + .asOpt[Seq[JsObject]] + .map { arr => arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long]) - }.getOrElse(Seq.empty) - .groupBy { case (name, _) => name } - .map { case (a, b) => a -> b.map(_._2) } - } - else { - log.debug(s"Cannot retrieve workout list, response: $res") - Future.failed(new Error("Cannot retrieve workout list from Garmin Connect")) - } - }) + } + .getOrElse(Seq.empty) + .groupBy { case (name, _) => name } + .map { case (a, b) => a -> b.map(_._2) } + } else { + log.debug(s"Cannot retrieve workout list, response: $res") + Future.failed(new Error("Cannot retrieve workout list from Garmin Connect")) + } + }) source.runWith(Sink.head) } @@ -164,8 +167,8 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste ask(loginActor, Login(forceNewSession))(Timeout(2.minutes)).mapTo[Either[String, GarminSession]] /** - * Holds and reloads session if neccessary - */ + * Holds and reloads session if neccessary + */ class LoginActor extends Actor { private case class NewSession(session: GarminSession) @@ -246,19 +249,19 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste //"rememberMeChecked" -> "false", //"rememberMeShown" -> "true", "service" -> "https://connect.garmin.com/modern" - //"source" -> "https://connect.garmin.com/en-US/signin", - //"webhost" -> "https://connect.garmin.com" + //"source" -> "https://connect.garmin.com/en-US/signin", + //"webhost" -> "https://connect.garmin.com" ) for { res1 <- Http().singleRequest(HttpRequest(uri = Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)))).withoutBody - res2 <- Http().singleRequest( - HttpRequest( - POST, - Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)), - entity = FormData(Map( - "username" -> email, - "password" -> password, - "embed" -> "false")).toEntity).withHeaders(extractCookies(res1))).withoutBody + res2 <- Http() + .singleRequest( + HttpRequest( + POST, + Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)), + entity = FormData(Map("username" -> email, "password" -> password, "embed" -> "false")).toEntity + ).withHeaders(extractCookies(res1))) + .withoutBody sessionCookies <- redirectionLoop(0, "https://connect.garmin.com/modern", extractCookies(res2)) } yield GarminSession(sessionCookies) } diff --git a/src/main/scala/com.github.mgifos.workouts/Main.scala b/src/main/scala/com.github.mgifos.workouts/Main.scala index ffab296..34b3574 100644 --- a/src/main/scala/com.github.mgifos.workouts/Main.scala +++ b/src/main/scala/com.github.mgifos.workouts/Main.scala @@ -1,18 +1,18 @@ package com.github.mgifos.workouts -import java.nio.file.{ Files, Paths } +import java.nio.file.{Files, Paths} import java.time.LocalDate import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.stream.ActorMaterializer -import com.github.mgifos.workouts.model.{ WeeklyPlan, _ } +import com.github.mgifos.workouts.model.{WeeklyPlan, _} import com.typesafe.scalalogging.Logger import scopt.OptionParser import scala.collection.immutable.Seq import scala.concurrent.duration._ -import scala.concurrent.{ Await, ExecutionContextExecutor, Future } +import scala.concurrent.{Await, ExecutionContextExecutor, Future} object Modes extends Enumeration { type Mode = Value @@ -20,15 +20,14 @@ object Modes extends Enumeration { val schedule: Mode = Value("schedule") } -case class Config( - mode: Option[Modes.Mode] = None, - system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric, - csv: String = "", - delete: Boolean = false, - email: String = "", - password: String = "", - start: LocalDate = LocalDate.MIN, - end: LocalDate = LocalDate.MIN) +case class Config(mode: Option[Modes.Mode] = None, + system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric, + csv: String = "", + delete: Boolean = false, + email: String = "", + password: String = "", + start: LocalDate = LocalDate.MIN, + end: LocalDate = LocalDate.MIN) object Main extends App { @@ -59,10 +58,13 @@ object Main extends App { opt[String]('p', "password").action((x, c) => c.copy(password = x)).text("Password to login to Garmin Connect") - opt[MeasurementSystems.MeasurementSystem]('m', "measurement_system").action((x, c) => c.copy(system = x)).text(""""metric" (default) or "imperial" (miles, inches, ...) measurement system choice.""") + opt[MeasurementSystems.MeasurementSystem]('m', "measurement_system") + .action((x, c) => c.copy(system = x)) + .text(""""metric" (default) or "imperial" (miles, inches, ...) measurement system choice.""") - opt[Unit]('x', "delete").action((_, c) => c.copy(delete = true)).text( - "Delete all existing workouts with same names as the ones contained within the file. In case of import/schedule commands, " + + opt[Unit]('x', "delete") + .action((_, c) => c.copy(delete = true)) + .text("Delete all existing workouts with same names as the ones contained within the file. In case of import/schedule commands, " + "this will be done before the actual action.") help("help").text("prints this usage text") @@ -73,27 +75,32 @@ object Main extends App { note("\n") - cmd("import"). - action((_, c) => c.copy(mode = Some(Modes.`import`))).text( - "Imports all workout definitions from CSV file. If it's omitted, it is will be on by default.") + cmd("import") + .action((_, c) => c.copy(mode = Some(Modes.`import`))) + .text("Imports all workout definitions from CSV file. If it's omitted, it is will be on by default.") note("") - cmd("schedule").action((_, c) => c.copy(mode = Some(Modes.schedule))).text( - "Schedules your weekly plan defined in CSV in Garmin Connect calendar, starting from the first day of first week or" + - " ending on the last day of the last week. Either start or end date must be entered so the scheduling can be done" + - " properly. In case both are entered, start date has priority. All dates have to be entered in ISO date format" + - " e.g. '2018-03-24'.\n") + cmd("schedule") + .action((_, c) => c.copy(mode = Some(Modes.schedule))) + .text( + "Schedules your weekly plan defined in CSV in Garmin Connect calendar, starting from the first day of first week or" + + " ending on the last day of the last week. Either start or end date must be entered so the scheduling can be done" + + " properly. In case both are entered, start date has priority. All dates have to be entered in ISO date format" + + " e.g. '2018-03-24'.\n") .children( opt[String]('s', "start").action((x, c) => c.copy(start = LocalDate.parse(x))).text("Date of the first day of the first week of the plan"), opt[String]('n', "end").action((x, c) => c.copy(end = LocalDate.parse(x))).text("Date of the last day of the last week of the plan\n"), - checkConfig(c => - if (c.mode.contains(Modes.schedule) && c.start.isEqual(LocalDate.MIN) && c.end.isEqual(LocalDate.MIN)) - failure("Either start or end date must be entered!") - else success)) + checkConfig( + c => + if (c.mode.contains(Modes.schedule) && c.start.isEqual(LocalDate.MIN) && c.end.isEqual(LocalDate.MIN)) + failure("Either start or end date must be entered!") + else success) + ) - note("EXAMPLES").text("EXAMPLES\n\nSchedules ultra 80k plan targeting 28-4-2018 for a race day (also deletes existing workouts with the same names)" + - "\n\nquick-plan schedule -n 2018-04-29 -x -e your-mail-address@example.com ultra-80k-runnersworld.csv") + note("EXAMPLES").text( + "EXAMPLES\n\nSchedules ultra 80k plan targeting 28-4-2018 for a race day (also deletes existing workouts with the same names)" + + "\n\nquick-plan schedule -n 2018-04-29 -x -e your-mail-address@example.com ultra-80k-runnersworld.csv") note("") @@ -107,14 +114,18 @@ object Main extends App { val console = System.console() 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 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()) + } implicit val garmin: GarminConnect = new GarminConnect(email, password) val workouts = plan.workouts.toIndexedSeq @@ -146,41 +157,50 @@ object Main extends App { print("Do you want to proceed to Garmin by skipping these items? [Y/n]") "" + console.readLine() match { case "" | "y" | "Y" => proceedToGarmin() - case _ => Future.successful(()) + case _ => Future.successful(()) } } } /** - * Deletes existing workouts with the same names or not - */ - private def deleteWorkoutsTask(workouts: Seq[String])(implicit config: Config, garmin: GarminConnect, session: GarminSession): Future[Option[String]] = { + * Deletes existing workouts with the same names or not + */ + private def deleteWorkoutsTask( + workouts: Seq[String])(implicit config: Config, garmin: GarminConnect, session: GarminSession): Future[Option[String]] = { if (config.delete) garmin.deleteWorkouts(workouts).map(c => Some(s"$c deleted")) else Future.successful(None) } - private def createWorkoutsTask(workouts: Seq[WorkoutDef])(implicit config: Config, garmin: GarminConnect, session: GarminSession): Future[Option[Seq[GarminWorkout]]] = { + private def createWorkoutsTask( + workouts: Seq[WorkoutDef])(implicit config: Config, garmin: GarminConnect, session: GarminSession): Future[Option[Seq[GarminWorkout]]] = { if (config.mode.exists(Seq(Modes.`import`, Modes.schedule).contains)) garmin.createWorkouts(workouts).map(Option.apply) else Future.successful(None) } - private def scheduleTask(workouts: Seq[GarminWorkout])(implicit config: Config, garmin: GarminConnect, plan: WeeklyPlan, session: GarminSession): Future[Option[String]] = { + private def scheduleTask(workouts: Seq[GarminWorkout])(implicit config: Config, + garmin: GarminConnect, + plan: WeeklyPlan, + session: GarminSession): Future[Option[String]] = { 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 (from, _) => from + case (from, _) => from } val woMap: Map[String, GarminWorkout] = Map(workouts.map(ga => ga.name -> ga): _*) - 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] + 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")) } else Future.successful(None) diff --git a/src/main/scala/com.github.mgifos.workouts/model/Duration.scala b/src/main/scala/com.github.mgifos.workouts/model/Duration.scala index 3362428..5ef8147 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Duration.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Duration.scala @@ -1,44 +1,43 @@ package com.github.mgifos.workouts.model import com.github.mgifos.workouts.model.DistanceUnits.DistanceUnit -import play.api.libs.json.{ JsNull, JsObject, Json } +import play.api.libs.json.{JsNull, JsObject, Json} sealed trait Duration { def json: JsObject } case class DistanceDuration(distance: Float, unit: DistanceUnit) extends Duration { - override def json: JsObject = Json.obj( - "endCondition" -> Json.obj( - "conditionTypeKey" -> "distance", - "conditionTypeId" -> 3), - "preferredEndConditionUnit" -> Json.obj( - "unitKey" -> unit.fullName), - "endConditionValue" -> unit.toMeters(distance), - "endConditionCompare" -> JsNull, - "endConditionZone" -> JsNull) + override def json: JsObject = + Json.obj( + "endCondition" -> Json.obj("conditionTypeKey" -> "distance", "conditionTypeId" -> 3), + "preferredEndConditionUnit" -> Json.obj("unitKey" -> unit.fullName), + "endConditionValue" -> unit.toMeters(distance), + "endConditionCompare" -> JsNull, + "endConditionZone" -> JsNull + ) } case class TimeDuration(minutes: Int = 0, seconds: Int = 0) extends Duration { - override def json: JsObject = Json.obj( - "endCondition" -> Json.obj( - "conditionTypeKey" -> "time", - "conditionTypeId" -> 2), - "preferredEndConditionUnit" -> JsNull, - "endConditionValue" -> (minutes * 60 + seconds), - "endConditionCompare" -> JsNull, - "endConditionZone" -> JsNull) + override def json: JsObject = + Json.obj( + "endCondition" -> Json.obj("conditionTypeKey" -> "time", "conditionTypeId" -> 2), + "preferredEndConditionUnit" -> JsNull, + "endConditionValue" -> (minutes * 60 + seconds), + "endConditionCompare" -> JsNull, + "endConditionZone" -> JsNull + ) } object LapButtonPressed extends Duration { - override def json: JsObject = Json.obj( - "endCondition" -> Json.obj( - "conditionTypeKey" -> "lap.button", - "conditionTypeId" -> 1), - "preferredEndConditionUnit" -> JsNull, - "endConditionValue" -> JsNull, - "endConditionCompare" -> JsNull, - "endConditionZone" -> JsNull) + override def json: JsObject = + Json.obj( + "endCondition" -> Json.obj("conditionTypeKey" -> "lap.button", "conditionTypeId" -> 1), + "preferredEndConditionUnit" -> JsNull, + "endConditionValue" -> JsNull, + "endConditionCompare" -> JsNull, + "endConditionZone" -> JsNull + ) } object Duration { @@ -48,9 +47,8 @@ object Duration { def parse(x: String): Duration = x match { case DistanceRx(quantity, _, uom) => DistanceDuration(quantity.toFloat, DistanceUnits.named(uom)) - case MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt) - case "lap-button" => LapButtonPressed - case _ => throw new IllegalArgumentException(s"Duration cannot be parsed $x") + case MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt) + case "lap-button" => LapButtonPressed + case _ => throw new IllegalArgumentException(s"Duration cannot be parsed $x") } } - 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 9f06545..224e395 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Step.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Step.scala @@ -1,6 +1,6 @@ package com.github.mgifos.workouts.model -import play.api.libs.json.{ JsNull, JsObject, JsValue, Json } +import play.api.libs.json.{JsNull, JsObject, JsValue, Json} trait Step { def `type`: String @@ -8,21 +8,19 @@ trait Step { def json(order: Int): JsValue } -abstract class DurationStep( - override val `type`: String, - override val typeId: Int) extends Step { +abstract class DurationStep(override val `type`: String, override val typeId: Int) extends Step { def duration: Duration def target: Option[Target] - def json(order: Int): JsObject = Json.obj( - "type" -> "ExecutableStepDTO", - "stepId" -> JsNull, - "stepOrder" -> order, - "childStepId" -> JsNull, - "description" -> JsNull, - "stepType" -> Json.obj( - "stepTypeId" -> typeId, - "stepTypeKey" -> `type`)) ++ duration.json ++ target.fold(NoTarget.json)(_.json) + def json(order: Int): JsObject = + Json.obj( + "type" -> "ExecutableStepDTO", + "stepId" -> JsNull, + "stepOrder" -> order, + "childStepId" -> JsNull, + "description" -> JsNull, + "stepType" -> Json.obj("stepTypeId" -> typeId, "stepTypeKey" -> `type`) + ) ++ duration.json ++ target.fold(NoTarget.json)(_.json) } case class WarmupStep(duration: Duration, target: Option[Target] = None) extends DurationStep("warmup", 1) @@ -37,17 +35,17 @@ case class RepeatStep(count: Int, steps: Seq[Step]) extends Step { override def `type` = "repeat" override def typeId = 6 - override def json(order: Int) = Json.obj( - "stepId" -> JsNull, - "stepOrder" -> order, - "stepType" -> Json.obj( - "stepTypeId" -> typeId, - "stepTypeKey" -> "repeat"), - "numberOfIterations" -> count, - "smartRepeat" -> false, - "childStepId" -> 1, - "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) }, - "type" -> "RepeatGroupDTO") + override def json(order: Int) = + Json.obj( + "stepId" -> JsNull, + "stepOrder" -> order, + "stepType" -> Json.obj("stepTypeId" -> typeId, "stepTypeKey" -> "repeat"), + "numberOfIterations" -> count, + "smartRepeat" -> false, + "childStepId" -> 1, + "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) }, + "type" -> "RepeatGroupDTO" + ) } object Step { @@ -57,24 +55,26 @@ object Step { private val ParamsRx = """^([\w-\.:\s]+)\s*(@(.*))?$""".r def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Step = x match { - case StepRx(header, subSteps, _) if subSteps.nonEmpty => header match { - case StepHeader(name, params) => - 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") - } + case StepRx(header, subSteps, _) if subSteps.nonEmpty => + header match { + case StepHeader(name, params) => + 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") + } case StepRx(header, "", null) => parseDurationStep(header) - case _ => throw new IllegalArgumentException(s"Cannot parse step:$x") + case _ => throw new IllegalArgumentException(s"Cannot parse step:$x") } private def parseDurationStep(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): DurationStep = x match { - case StepHeader(name, params) => name match { - case "warmup" => WarmupStep.tupled(expect(params)) - 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"'$name' is not a duration step type") - } + case StepHeader(name, params) => + name match { + case "warmup" => WarmupStep.tupled(expect(params)) + 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"'$name' is not a duration step type") + } case _ => throw new IllegalArgumentException(s"Cannot parse duration step: $x") } @@ -85,4 +85,3 @@ object Step { 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 3668f40..dbd7eae 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Target.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Target.scala @@ -1,59 +1,59 @@ package com.github.mgifos.workouts.model -import play.api.libs.json.{ JsNull, JsObject, Json } +import play.api.libs.json.{JsNull, JsObject, Json} sealed trait Target { def json: JsObject } case class HrZoneTarget(zone: Int) extends Target { - override def json = Json.obj( - "targetType" -> Json.obj( - "workoutTargetTypeId" -> 4, - "workoutTargetTypeKey" -> "heart.rate.zone"), - "targetValueOne" -> "", - "targetValueTwo" -> "", - "zoneNumber" -> zone.toString) + override def json = + Json.obj( + "targetType" -> Json.obj("workoutTargetTypeId" -> 4, "workoutTargetTypeKey" -> "heart.rate.zone"), + "targetValueOne" -> "", + "targetValueTwo" -> "", + "zoneNumber" -> zone.toString + ) } case class HrCustomTarget(from: Int, to: Int) extends Target { - override def json = Json.obj( - "targetType" -> Json.obj( - "workoutTargetTypeId" -> 4, - "workoutTargetTypeKey" -> "heart.rate.zone"), - "targetValueOne" -> from, - "targetValueTwo" -> to, - "zoneNumber" -> JsNull) + override def json = + Json.obj( + "targetType" -> Json.obj("workoutTargetTypeId" -> 4, "workoutTargetTypeKey" -> "heart.rate.zone"), + "targetValueOne" -> from, + "targetValueTwo" -> to, + "zoneNumber" -> JsNull + ) } case class PaceTarget(from: Pace, to: Pace) extends Target { - override def json = Json.obj( - "targetType" -> Json.obj( - "workoutTargetTypeId" -> 6, - "workoutTargetTypeKey" -> "pace.zone"), - "targetValueOne" -> from.speed, - "targetValueTwo" -> to.speed, - "zoneNumber" -> JsNull) + override def json = + Json.obj( + "targetType" -> Json.obj("workoutTargetTypeId" -> 6, "workoutTargetTypeKey" -> "pace.zone"), + "targetValueOne" -> from.speed, + "targetValueTwo" -> to.speed, + "zoneNumber" -> JsNull + ) } case class SpeedTarget(from: Speed, to: Speed) extends Target { - override def json = Json.obj( - "targetType" -> Json.obj( - "workoutTargetTypeId" -> 5, - "workoutTargetTypeKey" -> "speed.zone"), - "targetValueOne" -> from.speed, - "targetValueTwo" -> to.speed, - "zoneNumber" -> JsNull) + override def json = + Json.obj( + "targetType" -> Json.obj("workoutTargetTypeId" -> 5, "workoutTargetTypeKey" -> "speed.zone"), + "targetValueOne" -> from.speed, + "targetValueTwo" -> to.speed, + "zoneNumber" -> JsNull + ) } object NoTarget extends Target { - override def json = Json.obj( - "targetType" -> Json.obj( - "workoutTargetTypeId" -> 1, - "workoutTargetTypeKey" -> "no.target"), - "targetValueOne" -> JsNull, - "targetValueTwo" -> JsNull, - "zoneNumber" -> JsNull) + override def json = + Json.obj( + "targetType" -> Json.obj("workoutTargetTypeId" -> 1, "workoutTargetTypeKey" -> "no.target"), + "targetValueOne" -> JsNull, + "targetValueTwo" -> JsNull, + "zoneNumber" -> JsNull + ) } case class Pace(uom: DistanceUnits.DistanceUnit, exp: String) { @@ -61,16 +61,16 @@ case class Pace(uom: DistanceUnits.DistanceUnit, exp: String) { def seconds: Int = exp.trim.split(":").last.toInt /** - * @return Speed in m/s - */ + * @return Speed in m/s + */ def speed: Double = uom.toMeters(1) / (minutes * 60 + seconds) } case class Speed(unit: DistanceUnits.DistanceUnit, exp: String) { /** - * @return Speed in m/s - */ + * @return Speed in m/s + */ def speed: Double = unit.toMeters(exp.toDouble) / 3600 } @@ -81,7 +81,7 @@ object Target { private val SpeedRangeRx = """^(\d{1,3}(\.\d{1})?)\s*-\s*(\d{1,3}(\.\d{1})?)\s*(kph|mph)?""".r def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Target = x.trim match { - case HrZoneRx(zone) => HrZoneTarget(zone.toInt) + case HrZoneRx(zone) => HrZoneTarget(zone.toInt) case HrCustomRx(from, to) => HrCustomTarget(from.toInt, to.toInt) case SpeedRangeRx(from, _, to, _, uom) => val du = Option(uom).fold(msys.distance)(DistanceUnits.withSpeedUOM) @@ -91,4 +91,4 @@ object Target { PaceTarget(Pace(du, from), Pace(du, to)) 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/Week.scala b/src/main/scala/com.github.mgifos.workouts/model/Week.scala index 57539ec..27692dc 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Week.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Week.scala @@ -3,4 +3,4 @@ package com.github.mgifos.workouts.model object Week extends Enumeration { type Week = Value val Mon, Tue, Wed, Thu, Fri, Sat, Sun = Value -} \ 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 5e8828e..f0efedb 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala @@ -10,17 +10,21 @@ class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.Measurement private lazy val processed: Seq[Option[Workout]] = { - 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.parse(dayText) match { - case note: WorkoutNote => onlyDefs(previousWeeks ++ acc).find(_.name == dayText).map(_.toRef).getOrElse(note) - case w: Workout => w - } - }) + 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.parse(dayText) match { + case note: WorkoutNote => onlyDefs(previousWeeks ++ acc).find(_.name == dayText).map(_.toRef).getOrElse(note) + case w: Workout => w + } + }) def loop(weeks: List[Week], acc: Seq[Option[Workout]]): Seq[Option[Workout]] = weeks match { - case Nil => acc + case Nil => acc case week :: rest => loop(rest, acc ++ weekPlan(week, acc)) } @@ -28,17 +32,17 @@ class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.Measurement } /** - * @return all workout definitions defined in this plan - */ + * @return all workout definitions defined in this plan + */ def workouts: Seq[WorkoutDef] = onlyDefs(processed) /** - * @return optional workout refs (defs included as refs) - */ + * @return optional workout refs (defs included as refs) + */ def get(): Seq[Option[WorkoutRef]] = processed.map { case Some(x: WorkoutDef) => Some(x.toRef) case Some(x: WorkoutRef) => Some(x) - case _ => None + case _ => None } def invalid(): Seq[Workout] = processed.collect { @@ -49,6 +53,6 @@ class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.Measurement private def onlyDefs(days: Seq[Option[Workout]]) = days.flatMap { case Some(wdef: WorkoutDef) => Some(wdef) - case _ => None + case _ => None } -} \ No newline at end of file +} 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 274d119..4463c6f 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Workout.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala @@ -1,6 +1,6 @@ package com.github.mgifos.workouts.model -import play.api.libs.json.{ JsValue, Json } +import play.api.libs.json.{JsValue, Json} import Workout._ trait Workout { @@ -11,18 +11,17 @@ trait Workout { 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) - override def json(): JsValue = Json.obj( - "sportType" -> Json.obj( - "sportTypeId" -> sportId(sport), - "sportTypeKey" -> sportTypeKey(sport)), - "workoutName" -> name, - "workoutSegments" -> Json.arr( - Json.obj( - "segmentOrder" -> 1, - "sportType" -> Json.obj( - "sportTypeId" -> sportId(sport), - "sportTypeKey" -> sport), - "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) }))) + override def json(): JsValue = + Json.obj( + "sportType" -> Json.obj("sportTypeId" -> sportId(sport), "sportTypeKey" -> sportTypeKey(sport)), + "workoutName" -> name, + "workoutSegments" -> Json.arr( + Json.obj( + "segmentOrder" -> 1, + "sportType" -> Json.obj("sportTypeId" -> sportId(sport), "sportTypeKey" -> sport), + "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) } + )) + ) } case class WorkoutDefFailure(`type`: String, original: String, cause: String) extends Workout { @@ -60,20 +59,20 @@ object Workout { } text match { case WorkoutHeader(sport, name, steps, _) => loop(WorkoutDef(sport, name), steps.trim) - case PossibleWorkoutHeader(t, _, cause) => WorkoutDefFailure(`type` = t, text, if (cause == null) "" else cause.trim) - case _ => WorkoutNote(text) + case PossibleWorkoutHeader(t, _, cause) => WorkoutDefFailure(`type` = t, text, if (cause == null) "" else cause.trim) + case _ => WorkoutNote(text) } } def sportId(sport: String) = sport match { case "running" => 1 case "cycling" => 2 - case "custom" => 3 - case _ => throw new IllegalArgumentException("Only running, cycling and 'custom' workouts are supported.") + case "custom" => 3 + case _ => throw new IllegalArgumentException("Only running, cycling and 'custom' workouts are supported.") } def sportTypeKey(sport: String) = sport match { case "custom" => "other" - case _ => sport + case _ => sport } } diff --git a/src/main/scala/com.github.mgifos.workouts/model/package.scala b/src/main/scala/com.github.mgifos.workouts/model/package.scala index b7b3183..725b2d3 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/package.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/package.scala @@ -15,12 +15,12 @@ package object model { def withPaceUOM(paceUom: String): DistVal = paceUom match { case "mpk" => km case "mpm" => mi - case _ => throw new IllegalArgumentException(s"No such pace unit of measurement: '$paceUom'") + case _ => throw new IllegalArgumentException(s"No such pace unit of measurement: '$paceUom'") } def withSpeedUOM(speedUom: String): DistVal = speedUom match { case "kph" => km case "mph" => mi - case _ => throw new IllegalArgumentException(s"No such speed unit of measurement: '$speedUom'") + case _ => throw new IllegalArgumentException(s"No such speed unit of measurement: '$speedUom'") } }