Formatted with new scalafmt format

master
mgifos 7 years ago
parent 21024285ae
commit f2bbd95745
  1. 3
      .scalafmt.conf
  2. 59
      src/main/scala/com.github.mgifos.workouts/GarminConnect.scala
  3. 64
      src/main/scala/com.github.mgifos.workouts/Main.scala
  4. 36
      src/main/scala/com.github.mgifos.workouts/model/Duration.scala
  5. 31
      src/main/scala/com.github.mgifos.workouts/model/Step.scala
  6. 52
      src/main/scala/com.github.mgifos.workouts/model/Target.scala
  7. 8
      src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala
  8. 17
      src/main/scala/com.github.mgifos.workouts/model/Workout.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
@ -48,14 +48,13 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
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
.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) {
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) {
@ -88,16 +87,16 @@ 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
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) {
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 =>
@ -127,7 +126,8 @@ 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)
}
/**
@ -137,20 +137,23 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
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
.withHeaders(
session.headers
:+ Referer("https://connect.garmin.com/modern/workouts")
:+ RawHeader("NK", "NT"))
val source = Source.fromFuture(
Http().singleRequest(req).flatMap { res =>
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 =>
Json
.parse(json)
.asOpt[Seq[JsObject]]
.map { arr =>
arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long])
}.getOrElse(Seq.empty)
}
.getOrElse(Seq.empty)
.groupBy { case (name, _) => name }
.map { case (a, b) => a -> b.map(_._2) }
}
else {
} else {
log.debug(s"Cannot retrieve workout list, response: $res")
Future.failed(new Error("Cannot retrieve workout list from Garmin Connect"))
}
@ -251,14 +254,14 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
)
for {
res1 <- Http().singleRequest(HttpRequest(uri = Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)))).withoutBody
res2 <- Http().singleRequest(
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
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,8 +20,7 @@ object Modes extends Enumeration {
val schedule: Mode = Value("schedule")
}
case class Config(
mode: Option[Modes.Mode] = None,
case class Config(mode: Option[Modes.Mode] = None,
system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric,
csv: String = "",
delete: Boolean = false,
@ -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,13 +75,15 @@ 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(
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" +
@ -87,12 +91,15 @@ object Main extends App {
.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 =>
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))
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(
"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,11 +114,15 @@ object Main extends App {
val console = System.console()
def proceedToGarmin() = {
val email = if (config.email.nonEmpty) config.email else {
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 {
val password =
if (config.password.nonEmpty) config.password
else {
print("Password: ")
new String(console.readPassword())
}
@ -154,21 +165,26 @@ object Main extends App {
/**
* 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)
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)) {
@ -178,9 +194,13 @@ object Main extends App {
}
val woMap: Map[String, GarminWorkout] = Map(workouts.map(ga => ga.name -> ga): _*)
val spec = plan.get().zipWithIndex.collect {
val spec = plan
.get()
.zipWithIndex
.collect {
case (Some(ref), day) if !start.plusDays(day).isBefore(LocalDate.now()) => start.plusDays(day) -> woMap(ref.name)
}.to[Seq]
}
.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),
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)
"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),
override def json: JsObject =
Json.obj(
"endCondition" -> Json.obj("conditionTypeKey" -> "time", "conditionTypeId" -> 2),
"preferredEndConditionUnit" -> JsNull,
"endConditionValue" -> (minutes * 60 + seconds),
"endConditionCompare" -> JsNull,
"endConditionZone" -> JsNull)
"endConditionZone" -> JsNull
)
}
object LapButtonPressed extends Duration {
override def json: JsObject = Json.obj(
"endCondition" -> Json.obj(
"conditionTypeKey" -> "lap.button",
"conditionTypeId" -> 1),
override def json: JsObject =
Json.obj(
"endCondition" -> Json.obj("conditionTypeKey" -> "lap.button", "conditionTypeId" -> 1),
"preferredEndConditionUnit" -> JsNull,
"endConditionValue" -> JsNull,
"endConditionCompare" -> JsNull,
"endConditionZone" -> JsNull)
"endConditionZone" -> JsNull
)
}
object Duration {
@ -53,4 +52,3 @@ object Duration {
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(
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)
"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(
override def json(order: Int) =
Json.obj(
"stepId" -> JsNull,
"stepOrder" -> order,
"stepType" -> Json.obj(
"stepTypeId" -> typeId,
"stepTypeKey" -> "repeat"),
"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")
"type" -> "RepeatGroupDTO"
)
}
object Step {
@ -57,7 +55,8 @@ 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 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))
@ -68,7 +67,8 @@ object Step {
}
private def parseDurationStep(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): DurationStep = x match {
case StepHeader(name, params) => name 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))
@ -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"),
override def json =
Json.obj(
"targetType" -> Json.obj("workoutTargetTypeId" -> 4, "workoutTargetTypeKey" -> "heart.rate.zone"),
"targetValueOne" -> "",
"targetValueTwo" -> "",
"zoneNumber" -> zone.toString)
"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"),
override def json =
Json.obj(
"targetType" -> Json.obj("workoutTargetTypeId" -> 4, "workoutTargetTypeKey" -> "heart.rate.zone"),
"targetValueOne" -> from,
"targetValueTwo" -> to,
"zoneNumber" -> JsNull)
"zoneNumber" -> JsNull
)
}
case class PaceTarget(from: Pace, to: Pace) extends Target {
override def json = Json.obj(
"targetType" -> Json.obj(
"workoutTargetTypeId" -> 6,
"workoutTargetTypeKey" -> "pace.zone"),
override def json =
Json.obj(
"targetType" -> Json.obj("workoutTargetTypeId" -> 6, "workoutTargetTypeKey" -> "pace.zone"),
"targetValueOne" -> from.speed,
"targetValueTwo" -> to.speed,
"zoneNumber" -> JsNull)
"zoneNumber" -> JsNull
)
}
case class SpeedTarget(from: Speed, to: Speed) extends Target {
override def json = Json.obj(
"targetType" -> Json.obj(
"workoutTargetTypeId" -> 5,
"workoutTargetTypeKey" -> "speed.zone"),
override def json =
Json.obj(
"targetType" -> Json.obj("workoutTargetTypeId" -> 5, "workoutTargetTypeKey" -> "speed.zone"),
"targetValueOne" -> from.speed,
"targetValueTwo" -> to.speed,
"zoneNumber" -> JsNull)
"zoneNumber" -> JsNull
)
}
object NoTarget extends Target {
override def json = Json.obj(
"targetType" -> Json.obj(
"workoutTargetTypeId" -> 1,
"workoutTargetTypeKey" -> "no.target"),
override def json =
Json.obj(
"targetType" -> Json.obj("workoutTargetTypeId" -> 1, "workoutTargetTypeKey" -> "no.target"),
"targetValueOne" -> JsNull,
"targetValueTwo" -> JsNull,
"zoneNumber" -> JsNull)
"zoneNumber" -> JsNull
)
}
case class Pace(uom: DistanceUnits.DistanceUnit, exp: String) {

@ -10,9 +10,13 @@ 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 =>
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 =>
}
.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

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

Loading…
Cancel
Save