Formatted with new scalafmt format

master
mgifos 7 years ago
parent 21024285ae
commit f2bbd95745
  1. 3
      .scalafmt.conf
  2. 165
      src/main/scala/com.github.mgifos.workouts/GarminConnect.scala
  3. 114
      src/main/scala/com.github.mgifos.workouts/Main.scala
  4. 58
      src/main/scala/com.github.mgifos.workouts/model/Duration.scala
  5. 77
      src/main/scala/com.github.mgifos.workouts/model/Step.scala
  6. 84
      src/main/scala/com.github.mgifos.workouts/model/Target.scala
  7. 2
      src/main/scala/com.github.mgifos.workouts/model/Week.scala
  8. 36
      src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala
  9. 35
      src/main/scala/com.github.mgifos.workouts/model/Workout.scala
  10. 4
      src/main/scala/com.github.mgifos.workouts/model/package.scala

@ -0,0 +1,3 @@
maxColumn = 150
includeCurlyBraceInSelectChains = false
optIn.breakChainOnFirstMethodDot = false

@ -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)
}

@ -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)

@ -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")
}
}

@ -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")
}
}

@ -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")
}
}
}

@ -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
}
}

@ -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
}
}
}

@ -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
}
}

@ -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'")
}
}

Loading…
Cancel
Save