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 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.Http
import akka.http.scaladsl.client.RequestBuilding._ import akka.http.scaladsl.client.RequestBuilding._
import akka.http.scaladsl.model.ContentTypes._ 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._
import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.model.headers._
import akka.pattern.ask import akka.pattern.ask
import akka.stream.scaladsl.{ Flow, Sink, Source } import akka.stream.scaladsl.{Flow, Sink, Source}
import akka.stream.{ Materializer, ThrottleMode } import akka.stream.{Materializer, ThrottleMode}
import akka.util.Timeout import akka.util.Timeout
import com.github.mgifos.workouts.GarminConnect._ import com.github.mgifos.workouts.GarminConnect._
import com.github.mgifos.workouts.model.WorkoutDef import com.github.mgifos.workouts.model.WorkoutDef
import com.typesafe.scalalogging.Logger 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.duration._
import scala.concurrent.{ ExecutionContext, Future } import scala.concurrent.{ExecutionContext, Future}
import scala.language.implicitConversions import scala.language.implicitConversions
import scala.util.Failure import scala.util.Failure
@ -37,47 +37,46 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
private val log = Logger(getClass) private val log = Logger(getClass)
/** /**
* Creates workout definitions * Creates workout definitions
* *
* @param workouts * @param workouts
* @return * @return
*/ */
def createWorkouts(workouts: Seq[WorkoutDef])(implicit session: GarminSession): Future[Seq[GarminWorkout]] = { def createWorkouts(workouts: Seq[WorkoutDef])(implicit session: GarminSession): Future[Seq[GarminWorkout]] = {
log.info("\nCreating workouts:") log.info("\nCreating workouts:")
val source = Source(workouts.map { workout => val source = Source(workouts.map { workout =>
val req = Post("https://connect.garmin.com/modern/proxy/workout-service/workout") val req = Post("https://connect.garmin.com/modern/proxy/workout-service/workout")
.withEntity(HttpEntity(`application/json`, workout.json.toString())) .withEntity(HttpEntity(`application/json`, workout.json.toString()))
.withHeaders(session.headers .withHeaders(
:+ Referer("https://connect.garmin.com/modern/workout/create/running") session.headers
:+ RawHeader("NK", "NT")) :+ Referer("https://connect.garmin.com/modern/workout/create/running")
:+ RawHeader("NK", "NT"))
workout.name -> req workout.name -> req
}) })
val flow = Flow[(String, HttpRequest)] val flow = Flow[(String, HttpRequest)].throttle(1, 1.second, 1, ThrottleMode.shaping).mapAsync(1) {
.throttle(1, 1.second, 1, ThrottleMode.shaping) case (workout, req) =>
.mapAsync(1) { Http().singleRequest(req).flatMap { res =>
case (workout, req) => if (res.status == OK) {
Http().singleRequest(req).flatMap { res => res.body.map { json =>
if (res.status == OK) { log.info(s" $workout")
res.body.map { json => GarminWorkout(workout, Json.parse(json).\("workoutId").as[Long])
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"))
} }
} else {
log.debug(s"Creation wo response: $res")
Future.failed(new Error("Cannot create workout"))
} }
} }
}
source.via(flow).runWith(Sink.seq) source.via(flow).runWith(Sink.seq)
} }
/** /**
* Deletes workouts with provided names * Deletes workouts with provided names
* *
* @param workouts Workout names * @param workouts Workout names
* @return Count of deleted items * @return Count of deleted items
*/ */
def deleteWorkouts(workouts: Seq[String])(implicit session: GarminSession): Future[Int] = { def deleteWorkouts(workouts: Seq[String])(implicit session: GarminSession): Future[Int] = {
val futureRequests = getWorkoutsMap().map { wsMap => val futureRequests = getWorkoutsMap().map { wsMap =>
@ -88,23 +87,23 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
if ids.nonEmpty if ids.nonEmpty
} yield { } yield {
val label = s"$workout -> ${ids.mkString("[", ", ", "]")}" val label = s"$workout -> ${ids.mkString("[", ", ", "]")}"
label -> ids.map(id => Post(s"https://connect.garmin.com/modern/proxy/workout-service/workout/$id") label -> ids.map(
.withHeaders(session.headers id =>
:+ Referer("https://connect.garmin.com/modern/workouts") Post(s"https://connect.garmin.com/modern/proxy/workout-service/workout/$id").withHeaders(
:+ RawHeader("NK", "NT") session.headers
:+ RawHeader("X-HTTP-Method-Override", "DELETE"))) :+ Referer("https://connect.garmin.com/modern/workouts")
:+ RawHeader("NK", "NT")
:+ RawHeader("X-HTTP-Method-Override", "DELETE")))
} }
} }
val source = Source.fromFuture(futureRequests).flatMapConcat(Source(_)) val source = Source.fromFuture(futureRequests).flatMapConcat(Source(_)).throttle(1, 1.second, 1, ThrottleMode.shaping).mapAsync(1) {
.throttle(1, 1.second, 1, ThrottleMode.shaping) case (label, reqs) =>
.mapAsync(1) { val statusesFut = Future.sequence(reqs.map(req => Http().singleRequest(req).withoutBody))
case (label, reqs) => statusesFut.map { statuses =>
val statusesFut = Future.sequence(reqs.map(req => Http().singleRequest(req).withoutBody)) if (statuses.forall(_.status == NoContent)) log.info(s" $label")
statusesFut.map { statuses => else log.error(s" Cannot delete workout: $label")
if (statuses.forall(_.status == NoContent)) log.info(s" $label") }
else log.error(s" Cannot delete workout: $label") }
}
}
source.runWith(Sink.seq).map(_.length) 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") else log.error(s" Cannot schedule: $label")
} }
} }
.runWith(Sink.seq).map(_.length) .runWith(Sink.seq)
.map(_.length)
} }
/** /**
* Retrieves workout mapping: name -> Seq[id] @ GarminConnect * Retrieves workout mapping: name -> Seq[id] @ GarminConnect
* @return * @return
*/ */
private def getWorkoutsMap()(implicit session: GarminSession): Future[Map[String, Seq[Long]]] = { 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") val req = Get("https://connect.garmin.com/modern/proxy/workout-service/workouts?start=1&limit=9999&myWorkoutsOnly=true&sharedWorkoutsOnly=false")
.withHeaders(session.headers .withHeaders(
:+ Referer("https://connect.garmin.com/modern/workouts") session.headers
:+ RawHeader("NK", "NT")) :+ Referer("https://connect.garmin.com/modern/workouts")
val source = Source.fromFuture( :+ RawHeader("NK", "NT"))
Http().singleRequest(req).flatMap { res => val source = Source.fromFuture(Http().singleRequest(req).flatMap { res =>
if (res.status == OK) if (res.status == OK)
res.body.map { json => res.body.map { json =>
Json.parse(json).asOpt[Seq[JsObject]].map { arr => Json
.parse(json)
.asOpt[Seq[JsObject]]
.map { arr =>
arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long]) arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long])
}.getOrElse(Seq.empty) }
.groupBy { case (name, _) => name } .getOrElse(Seq.empty)
.map { case (a, b) => a -> b.map(_._2) } .groupBy { case (name, _) => name }
} .map { case (a, b) => a -> b.map(_._2) }
else { } else {
log.debug(s"Cannot retrieve workout list, response: $res") log.debug(s"Cannot retrieve workout list, response: $res")
Future.failed(new Error("Cannot retrieve workout list from Garmin Connect")) Future.failed(new Error("Cannot retrieve workout list from Garmin Connect"))
} }
}) })
source.runWith(Sink.head) 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]] 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 { class LoginActor extends Actor {
private case class NewSession(session: GarminSession) private case class NewSession(session: GarminSession)
@ -246,19 +249,19 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
//"rememberMeChecked" -> "false", //"rememberMeChecked" -> "false",
//"rememberMeShown" -> "true", //"rememberMeShown" -> "true",
"service" -> "https://connect.garmin.com/modern" "service" -> "https://connect.garmin.com/modern"
//"source" -> "https://connect.garmin.com/en-US/signin", //"source" -> "https://connect.garmin.com/en-US/signin",
//"webhost" -> "https://connect.garmin.com" //"webhost" -> "https://connect.garmin.com"
) )
for { for {
res1 <- Http().singleRequest(HttpRequest(uri = Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)))).withoutBody res1 <- Http().singleRequest(HttpRequest(uri = Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)))).withoutBody
res2 <- Http().singleRequest( res2 <- Http()
HttpRequest( .singleRequest(
POST, HttpRequest(
Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)), POST,
entity = FormData(Map( Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)),
"username" -> email, entity = FormData(Map("username" -> email, "password" -> password, "embed" -> "false")).toEntity
"password" -> password, ).withHeaders(extractCookies(res1)))
"embed" -> "false")).toEntity).withHeaders(extractCookies(res1))).withoutBody .withoutBody
sessionCookies <- redirectionLoop(0, "https://connect.garmin.com/modern", extractCookies(res2)) sessionCookies <- redirectionLoop(0, "https://connect.garmin.com/modern", extractCookies(res2))
} yield GarminSession(sessionCookies) } yield GarminSession(sessionCookies)
} }

@ -1,18 +1,18 @@
package com.github.mgifos.workouts package com.github.mgifos.workouts
import java.nio.file.{ Files, Paths } import java.nio.file.{Files, Paths}
import java.time.LocalDate import java.time.LocalDate
import akka.actor.ActorSystem import akka.actor.ActorSystem
import akka.http.scaladsl.Http import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer import akka.stream.ActorMaterializer
import com.github.mgifos.workouts.model.{ WeeklyPlan, _ } import com.github.mgifos.workouts.model.{WeeklyPlan, _}
import com.typesafe.scalalogging.Logger import com.typesafe.scalalogging.Logger
import scopt.OptionParser import scopt.OptionParser
import scala.collection.immutable.Seq import scala.collection.immutable.Seq
import scala.concurrent.duration._ import scala.concurrent.duration._
import scala.concurrent.{ Await, ExecutionContextExecutor, Future } import scala.concurrent.{Await, ExecutionContextExecutor, Future}
object Modes extends Enumeration { object Modes extends Enumeration {
type Mode = Value type Mode = Value
@ -20,15 +20,14 @@ object Modes extends Enumeration {
val schedule: Mode = Value("schedule") val schedule: Mode = Value("schedule")
} }
case class Config( case class Config(mode: Option[Modes.Mode] = None,
mode: Option[Modes.Mode] = None, system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric,
system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric, csv: String = "",
csv: String = "", delete: Boolean = false,
delete: Boolean = false, email: String = "",
email: String = "", password: String = "",
password: String = "", start: LocalDate = LocalDate.MIN,
start: LocalDate = LocalDate.MIN, end: LocalDate = LocalDate.MIN)
end: LocalDate = LocalDate.MIN)
object Main extends App { 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[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( opt[Unit]('x', "delete")
"Delete all existing workouts with same names as the ones contained within the file. In case of import/schedule commands, " + .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.") "this will be done before the actual action.")
help("help").text("prints this usage text") help("help").text("prints this usage text")
@ -73,27 +75,32 @@ object Main extends App {
note("\n") note("\n")
cmd("import"). cmd("import")
action((_, c) => c.copy(mode = Some(Modes.`import`))).text( .action((_, c) => c.copy(mode = Some(Modes.`import`)))
"Imports all workout definitions from CSV file. If it's omitted, it is will be on by default.") .text("Imports all workout definitions from CSV file. If it's omitted, it is will be on by default.")
note("") note("")
cmd("schedule").action((_, c) => c.copy(mode = Some(Modes.schedule))).text( cmd("schedule")
"Schedules your weekly plan defined in CSV in Garmin Connect calendar, starting from the first day of first week or" + .action((_, c) => c.copy(mode = Some(Modes.schedule)))
" ending on the last day of the last week. Either start or end date must be entered so the scheduling can be done" + .text(
" properly. In case both are entered, start date has priority. All dates have to be entered in ISO date format" + "Schedules your weekly plan defined in CSV in Garmin Connect calendar, starting from the first day of first week or" +
" e.g. '2018-03-24'.\n") " 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( .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]('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"), 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 => checkConfig(
if (c.mode.contains(Modes.schedule) && c.start.isEqual(LocalDate.MIN) && c.end.isEqual(LocalDate.MIN)) c =>
failure("Either start or end date must be entered!") if (c.mode.contains(Modes.schedule) && c.start.isEqual(LocalDate.MIN) && c.end.isEqual(LocalDate.MIN))
else success)) 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)" + note("EXAMPLES").text(
"\n\nquick-plan schedule -n 2018-04-29 -x -e your-mail-address@example.com ultra-80k-runnersworld.csv") "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("") note("")
@ -107,14 +114,18 @@ object Main extends App {
val console = System.console() val console = System.console()
def proceedToGarmin() = { def proceedToGarmin() = {
val email = if (config.email.nonEmpty) config.email else { val email =
print("Please enter your email address to login to Garmin Connect: ") if (config.email.nonEmpty) config.email
console.readLine() else {
} print("Please enter your email address to login to Garmin Connect: ")
val password = if (config.password.nonEmpty) config.password else { console.readLine()
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) implicit val garmin: GarminConnect = new GarminConnect(email, password)
val workouts = plan.workouts.toIndexedSeq 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]") print("Do you want to proceed to Garmin by skipping these items? [Y/n]")
"" + console.readLine() match { "" + console.readLine() match {
case "" | "y" | "Y" => proceedToGarmin() case "" | "y" | "Y" => proceedToGarmin()
case _ => Future.successful(()) case _ => Future.successful(())
} }
} }
} }
/** /**
* Deletes existing workouts with the same names or not * 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]] = { private def deleteWorkoutsTask(
workouts: Seq[String])(implicit config: Config, garmin: GarminConnect, session: GarminSession): Future[Option[String]] = {
if (config.delete) if (config.delete)
garmin.deleteWorkouts(workouts).map(c => Some(s"$c deleted")) garmin.deleteWorkouts(workouts).map(c => Some(s"$c deleted"))
else else
Future.successful(None) 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)) if (config.mode.exists(Seq(Modes.`import`, Modes.schedule).contains))
garmin.createWorkouts(workouts).map(Option.apply) garmin.createWorkouts(workouts).map(Option.apply)
else else
Future.successful(None) 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)) { if (config.mode.contains(Modes.schedule)) {
val start = (config.start, config.end) match { 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 case (from, _) => from
} }
val woMap: Map[String, GarminWorkout] = Map(workouts.map(ga => ga.name -> ga): _*) val woMap: Map[String, GarminWorkout] = Map(workouts.map(ga => ga.name -> ga): _*)
val spec = plan.get().zipWithIndex.collect { val spec = plan
case (Some(ref), day) if !start.plusDays(day).isBefore(LocalDate.now()) => start.plusDays(day) -> woMap(ref.name) .get()
}.to[Seq] .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")) garmin.schedule(spec).map(c => Some(s"$c scheduled"))
} else } else
Future.successful(None) Future.successful(None)

@ -1,44 +1,43 @@
package com.github.mgifos.workouts.model package com.github.mgifos.workouts.model
import com.github.mgifos.workouts.model.DistanceUnits.DistanceUnit 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 { sealed trait Duration {
def json: JsObject def json: JsObject
} }
case class DistanceDuration(distance: Float, unit: DistanceUnit) extends Duration { case class DistanceDuration(distance: Float, unit: DistanceUnit) extends Duration {
override def json: JsObject = Json.obj( override def json: JsObject =
"endCondition" -> Json.obj( Json.obj(
"conditionTypeKey" -> "distance", "endCondition" -> Json.obj("conditionTypeKey" -> "distance", "conditionTypeId" -> 3),
"conditionTypeId" -> 3), "preferredEndConditionUnit" -> Json.obj("unitKey" -> unit.fullName),
"preferredEndConditionUnit" -> Json.obj( "endConditionValue" -> unit.toMeters(distance),
"unitKey" -> unit.fullName), "endConditionCompare" -> JsNull,
"endConditionValue" -> unit.toMeters(distance), "endConditionZone" -> JsNull
"endConditionCompare" -> JsNull, )
"endConditionZone" -> JsNull)
} }
case class TimeDuration(minutes: Int = 0, seconds: Int = 0) extends Duration { case class TimeDuration(minutes: Int = 0, seconds: Int = 0) extends Duration {
override def json: JsObject = Json.obj( override def json: JsObject =
"endCondition" -> Json.obj( Json.obj(
"conditionTypeKey" -> "time", "endCondition" -> Json.obj("conditionTypeKey" -> "time", "conditionTypeId" -> 2),
"conditionTypeId" -> 2), "preferredEndConditionUnit" -> JsNull,
"preferredEndConditionUnit" -> JsNull, "endConditionValue" -> (minutes * 60 + seconds),
"endConditionValue" -> (minutes * 60 + seconds), "endConditionCompare" -> JsNull,
"endConditionCompare" -> JsNull, "endConditionZone" -> JsNull
"endConditionZone" -> JsNull) )
} }
object LapButtonPressed extends Duration { object LapButtonPressed extends Duration {
override def json: JsObject = Json.obj( override def json: JsObject =
"endCondition" -> Json.obj( Json.obj(
"conditionTypeKey" -> "lap.button", "endCondition" -> Json.obj("conditionTypeKey" -> "lap.button", "conditionTypeId" -> 1),
"conditionTypeId" -> 1), "preferredEndConditionUnit" -> JsNull,
"preferredEndConditionUnit" -> JsNull, "endConditionValue" -> JsNull,
"endConditionValue" -> JsNull, "endConditionCompare" -> JsNull,
"endConditionCompare" -> JsNull, "endConditionZone" -> JsNull
"endConditionZone" -> JsNull) )
} }
object Duration { object Duration {
@ -48,9 +47,8 @@ object Duration {
def parse(x: String): Duration = x match { def parse(x: String): Duration = x match {
case DistanceRx(quantity, _, uom) => DistanceDuration(quantity.toFloat, DistanceUnits.named(uom)) case DistanceRx(quantity, _, uom) => DistanceDuration(quantity.toFloat, DistanceUnits.named(uom))
case MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt) case MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt)
case "lap-button" => LapButtonPressed case "lap-button" => LapButtonPressed
case _ => throw new IllegalArgumentException(s"Duration cannot be parsed $x") case _ => throw new IllegalArgumentException(s"Duration cannot be parsed $x")
} }
} }

@ -1,6 +1,6 @@
package com.github.mgifos.workouts.model 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 { trait Step {
def `type`: String def `type`: String
@ -8,21 +8,19 @@ trait Step {
def json(order: Int): JsValue def json(order: Int): JsValue
} }
abstract class DurationStep( abstract class DurationStep(override val `type`: String, override val typeId: Int) extends Step {
override val `type`: String,
override val typeId: Int) extends Step {
def duration: Duration def duration: Duration
def target: Option[Target] def target: Option[Target]
def json(order: Int): JsObject = Json.obj( def json(order: Int): JsObject =
"type" -> "ExecutableStepDTO", Json.obj(
"stepId" -> JsNull, "type" -> "ExecutableStepDTO",
"stepOrder" -> order, "stepId" -> JsNull,
"childStepId" -> JsNull, "stepOrder" -> order,
"description" -> JsNull, "childStepId" -> JsNull,
"stepType" -> Json.obj( "description" -> JsNull,
"stepTypeId" -> typeId, "stepType" -> Json.obj("stepTypeId" -> typeId, "stepTypeKey" -> `type`)
"stepTypeKey" -> `type`)) ++ duration.json ++ target.fold(NoTarget.json)(_.json) ) ++ duration.json ++ target.fold(NoTarget.json)(_.json)
} }
case class WarmupStep(duration: Duration, target: Option[Target] = None) extends DurationStep("warmup", 1) 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 `type` = "repeat"
override def typeId = 6 override def typeId = 6
override def json(order: Int) = Json.obj( override def json(order: Int) =
"stepId" -> JsNull, Json.obj(
"stepOrder" -> order, "stepId" -> JsNull,
"stepType" -> Json.obj( "stepOrder" -> order,
"stepTypeId" -> typeId, "stepType" -> Json.obj("stepTypeId" -> typeId, "stepTypeKey" -> "repeat"),
"stepTypeKey" -> "repeat"), "numberOfIterations" -> count,
"numberOfIterations" -> count, "smartRepeat" -> false,
"smartRepeat" -> false, "childStepId" -> 1,
"childStepId" -> 1, "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) },
"workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) }, "type" -> "RepeatGroupDTO"
"type" -> "RepeatGroupDTO") )
} }
object Step { object Step {
@ -57,24 +55,26 @@ object Step {
private val ParamsRx = """^([\w-\.:\s]+)\s*(@(.*))?$""".r private val ParamsRx = """^([\w-\.:\s]+)\s*(@(.*))?$""".r
def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Step = x match { def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Step = x match {
case StepRx(header, subSteps, _) if subSteps.nonEmpty => header match { case StepRx(header, subSteps, _) if subSteps.nonEmpty =>
case StepHeader(name, params) => header match {
if (name != "repeat") throw new IllegalArgumentException(s"'$name' cannot contain sub-steps, it must be 'repeat'") case StepHeader(name, params) =>
RepeatStep(params.trim.toInt, subSteps.trim.lines.toList.map(parseDurationStep)) if (name != "repeat") throw new IllegalArgumentException(s"'$name' cannot contain sub-steps, it must be 'repeat'")
case _ => throw new IllegalArgumentException(s"Cannot parse repeat step $header") 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 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 { private def parseDurationStep(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): DurationStep = x match {
case StepHeader(name, params) => name match { case StepHeader(name, params) =>
case "warmup" => WarmupStep.tupled(expect(params)) name match {
case "run" | "bike" | "go" => IntervalStep.tupled(expect(params)) case "warmup" => WarmupStep.tupled(expect(params))
case "recover" => RecoverStep.tupled(expect(params)) case "run" | "bike" | "go" => IntervalStep.tupled(expect(params))
case "cooldown" => CooldownStep.tupled(expect(params)) case "recover" => RecoverStep.tupled(expect(params))
case _ => throw new IllegalArgumentException(s"'$name' is not a duration step type") 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") 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") case raw => throw new IllegalArgumentException(s"Cannot parse step parameters $raw")
} }
} }

@ -1,59 +1,59 @@
package com.github.mgifos.workouts.model 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 { sealed trait Target {
def json: JsObject def json: JsObject
} }
case class HrZoneTarget(zone: Int) extends Target { case class HrZoneTarget(zone: Int) extends Target {
override def json = Json.obj( override def json =
"targetType" -> Json.obj( Json.obj(
"workoutTargetTypeId" -> 4, "targetType" -> Json.obj("workoutTargetTypeId" -> 4, "workoutTargetTypeKey" -> "heart.rate.zone"),
"workoutTargetTypeKey" -> "heart.rate.zone"), "targetValueOne" -> "",
"targetValueOne" -> "", "targetValueTwo" -> "",
"targetValueTwo" -> "", "zoneNumber" -> zone.toString
"zoneNumber" -> zone.toString) )
} }
case class HrCustomTarget(from: Int, to: Int) extends Target { case class HrCustomTarget(from: Int, to: Int) extends Target {
override def json = Json.obj( override def json =
"targetType" -> Json.obj( Json.obj(
"workoutTargetTypeId" -> 4, "targetType" -> Json.obj("workoutTargetTypeId" -> 4, "workoutTargetTypeKey" -> "heart.rate.zone"),
"workoutTargetTypeKey" -> "heart.rate.zone"), "targetValueOne" -> from,
"targetValueOne" -> from, "targetValueTwo" -> to,
"targetValueTwo" -> to, "zoneNumber" -> JsNull
"zoneNumber" -> JsNull) )
} }
case class PaceTarget(from: Pace, to: Pace) extends Target { case class PaceTarget(from: Pace, to: Pace) extends Target {
override def json = Json.obj( override def json =
"targetType" -> Json.obj( Json.obj(
"workoutTargetTypeId" -> 6, "targetType" -> Json.obj("workoutTargetTypeId" -> 6, "workoutTargetTypeKey" -> "pace.zone"),
"workoutTargetTypeKey" -> "pace.zone"), "targetValueOne" -> from.speed,
"targetValueOne" -> from.speed, "targetValueTwo" -> to.speed,
"targetValueTwo" -> to.speed, "zoneNumber" -> JsNull
"zoneNumber" -> JsNull) )
} }
case class SpeedTarget(from: Speed, to: Speed) extends Target { case class SpeedTarget(from: Speed, to: Speed) extends Target {
override def json = Json.obj( override def json =
"targetType" -> Json.obj( Json.obj(
"workoutTargetTypeId" -> 5, "targetType" -> Json.obj("workoutTargetTypeId" -> 5, "workoutTargetTypeKey" -> "speed.zone"),
"workoutTargetTypeKey" -> "speed.zone"), "targetValueOne" -> from.speed,
"targetValueOne" -> from.speed, "targetValueTwo" -> to.speed,
"targetValueTwo" -> to.speed, "zoneNumber" -> JsNull
"zoneNumber" -> JsNull) )
} }
object NoTarget extends Target { object NoTarget extends Target {
override def json = Json.obj( override def json =
"targetType" -> Json.obj( Json.obj(
"workoutTargetTypeId" -> 1, "targetType" -> Json.obj("workoutTargetTypeId" -> 1, "workoutTargetTypeKey" -> "no.target"),
"workoutTargetTypeKey" -> "no.target"), "targetValueOne" -> JsNull,
"targetValueOne" -> JsNull, "targetValueTwo" -> JsNull,
"targetValueTwo" -> JsNull, "zoneNumber" -> JsNull
"zoneNumber" -> JsNull) )
} }
case class Pace(uom: DistanceUnits.DistanceUnit, exp: String) { 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 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) def speed: Double = uom.toMeters(1) / (minutes * 60 + seconds)
} }
case class Speed(unit: DistanceUnits.DistanceUnit, exp: String) { 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 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 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 { 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 HrCustomRx(from, to) => HrCustomTarget(from.toInt, to.toInt)
case SpeedRangeRx(from, _, to, _, uom) => case SpeedRangeRx(from, _, to, _, uom) =>
val du = Option(uom).fold(msys.distance)(DistanceUnits.withSpeedUOM) val du = Option(uom).fold(msys.distance)(DistanceUnits.withSpeedUOM)
@ -91,4 +91,4 @@ object Target {
PaceTarget(Pace(du, from), Pace(du, to)) PaceTarget(Pace(du, from), Pace(du, to))
case raw => throw new IllegalArgumentException(s"'$raw' is not a valid target specification") 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 { object Week extends Enumeration {
type Week = Value type Week = Value
val Mon, Tue, Wed, Thu, Fri, Sat, Sun = 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]] = { private lazy val processed: Seq[Option[Workout]] = {
def weekPlan(week: Week, previousWeeks: Seq[Option[Workout]]): Seq[Option[Workout]] = Seq.tabulate(7) { weekDayNo => def weekPlan(week: Week, previousWeeks: Seq[Option[Workout]]): Seq[Option[Workout]] =
week.lift(weekDayNo + 1).flatMap(text => Option(text.trim).filter(_.nonEmpty)) Seq
}.foldLeft(Seq.empty[Option[Workout]])((acc, maybeDayText) => acc :+ maybeDayText.map { dayText => .tabulate(7) { weekDayNo =>
Workout.parse(dayText) match { week.lift(weekDayNo + 1).flatMap(text => Option(text.trim).filter(_.nonEmpty))
case note: WorkoutNote => onlyDefs(previousWeeks ++ acc).find(_.name == dayText).map(_.toRef).getOrElse(note) }
case w: Workout => w .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 { 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)) 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) 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 { def get(): Seq[Option[WorkoutRef]] = processed.map {
case Some(x: WorkoutDef) => Some(x.toRef) case Some(x: WorkoutDef) => Some(x.toRef)
case Some(x: WorkoutRef) => Some(x) case Some(x: WorkoutRef) => Some(x)
case _ => None case _ => None
} }
def invalid(): Seq[Workout] = processed.collect { 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 { private def onlyDefs(days: Seq[Option[Workout]]) = days.flatMap {
case Some(wdef: WorkoutDef) => Some(wdef) case Some(wdef: WorkoutDef) => Some(wdef)
case _ => None case _ => None
} }
} }

@ -1,6 +1,6 @@
package com.github.mgifos.workouts.model package com.github.mgifos.workouts.model
import play.api.libs.json.{ JsValue, Json } import play.api.libs.json.{JsValue, Json}
import Workout._ import Workout._
trait Workout { trait Workout {
@ -11,18 +11,17 @@ trait Workout {
case class WorkoutDef(sport: String, name: String, steps: Seq[Step] = Nil) extends Workout { case class WorkoutDef(sport: String, name: String, steps: Seq[Step] = Nil) extends Workout {
def toRef: WorkoutRef = WorkoutRef(name) def toRef: WorkoutRef = WorkoutRef(name)
def withStep(step: Step): WorkoutDef = WorkoutDef(sport, name, steps :+ step) def withStep(step: Step): WorkoutDef = WorkoutDef(sport, name, steps :+ step)
override def json(): JsValue = Json.obj( override def json(): JsValue =
"sportType" -> Json.obj( Json.obj(
"sportTypeId" -> sportId(sport), "sportType" -> Json.obj("sportTypeId" -> sportId(sport), "sportTypeKey" -> sportTypeKey(sport)),
"sportTypeKey" -> sportTypeKey(sport)), "workoutName" -> name,
"workoutName" -> name, "workoutSegments" -> Json.arr(
"workoutSegments" -> Json.arr( Json.obj(
Json.obj( "segmentOrder" -> 1,
"segmentOrder" -> 1, "sportType" -> Json.obj("sportTypeId" -> sportId(sport), "sportTypeKey" -> sport),
"sportType" -> Json.obj( "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) }
"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 { case class WorkoutDefFailure(`type`: String, original: String, cause: String) extends Workout {
@ -60,20 +59,20 @@ object Workout {
} }
text match { text match {
case WorkoutHeader(sport, name, steps, _) => loop(WorkoutDef(sport, name), steps.trim) 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 PossibleWorkoutHeader(t, _, cause) => WorkoutDefFailure(`type` = t, text, if (cause == null) "" else cause.trim)
case _ => WorkoutNote(text) case _ => WorkoutNote(text)
} }
} }
def sportId(sport: String) = sport match { def sportId(sport: String) = sport match {
case "running" => 1 case "running" => 1
case "cycling" => 2 case "cycling" => 2
case "custom" => 3 case "custom" => 3
case _ => throw new IllegalArgumentException("Only running, cycling and 'custom' workouts are supported.") case _ => throw new IllegalArgumentException("Only running, cycling and 'custom' workouts are supported.")
} }
def sportTypeKey(sport: String) = sport match { def sportTypeKey(sport: String) = sport match {
case "custom" => "other" case "custom" => "other"
case _ => sport case _ => sport
} }
} }

@ -15,12 +15,12 @@ package object model {
def withPaceUOM(paceUom: String): DistVal = paceUom match { def withPaceUOM(paceUom: String): DistVal = paceUom match {
case "mpk" => km case "mpk" => km
case "mpm" => mi 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 { def withSpeedUOM(speedUom: String): DistVal = speedUom match {
case "kph" => km case "kph" => km
case "mph" => mi 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