Preparation for a release

master
mgifos 8 years ago
parent d93c6866d9
commit d1053e44dd
  1. 13
      README.md
  2. 8
      build.sbt
  3. 2
      project/plugins.sbt
  4. 41
      src/main/scala/com.github.mgifos.workouts/GarminConnect.scala
  5. 12
      src/main/scala/com.github.mgifos.workouts/Main.scala
  6. 1
      version.sbt

@ -26,6 +26,13 @@ An example of 2-weeks training plan, containing 2 workout definitions, 4 referen
| 2 | run-fast| ``workout: long-15`` <br> ``- run: 15 km @ z2``|rest|run-fast|rest|rest|long-15|
Checkout a [complete training plan for 80K ultra](https://docs.google.com/spreadsheets/d/1b1ZzrAFrjd-kvPq11zlbE2bWn2IQmUy0lBqIOFjqbwk/edit?usp=sharing). It was originally published in an article of Runner's world website - here's [the link](https://www.runnersworld.com/ultrarunning/the-ultimate-ultramarathon-training-plan).
## Installation
- Go to the [releases page](https://github.com/mgifos/quick-plan/releases) of this project
- Download latest release zip file and unzip it somewhere on your computer
- Enter bin folder and run `quick-plan` command (use `quick-plan.bat` if you are a Windows user)
## Command line options
```
@ -37,7 +44,7 @@ Usage: quick-plan [import|schedule] [options] <file>
-e, --email <value> E-mail to login to Garmin Connect
-p, --password <value> Password to login to Garmin Connect
-d, --delete Delete all existing workouts with same names as the ones that are going to be imported.
-x, --delete Delete all existing workouts with same names as the ones that are going to be imported.
--help prints this usage text
<file> File with a weekly based plan in CSV format
@ -55,13 +62,13 @@ EXAMPLES
Schedules ultra 80k plan targeting 28-4-2018 for a race day
quick-plan schedule -n 2018-04-29 -d -e your-mail-address@example.com ultra-80k-runnersworld.csv
quick-plan schedule -n 2018-04-29 -x -e your-mail-address@example.com ultra-80k-runnersworld.csv
```
## Workout notation
The reserved keywords of the notation are: workout, warmup, cooldown, run, repeat, recover and lap-button.
**`workout`** := `<header> <step>+`
**`<workout>`** := `<header> <step>+`
**`<header>`** := `workout: <name>`

@ -1,7 +1,13 @@
name := "workouts"
name := "quick-plan"
version := "0.1"
lazy val root = (project in file(".")).enablePlugins(
JavaAppPackaging,
UniversalDeployPlugin)
mainClass in Compile := Some("com.github.mgifos.workouts.Main")
scalaVersion := "2.12.4"
libraryDependencies ++= Seq(

@ -1 +1,3 @@
addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.2")
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.3")

@ -45,6 +45,7 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
def createWorkouts(workouts: Seq[WorkoutDef]): Future[Seq[GarminWorkout]] = {
val source = Source.fromFuture(login()).flatMapConcat { session =>
log.info("\nCreating workouts:")
Source(workouts.map { workout =>
val req = Post("https://connect.garmin.com/modern/proxy/workout-service/workout")
.withEntity(HttpEntity(`application/json`, workout.json.toString()))
@ -72,7 +73,6 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
}
}
log.info("\nCreating workouts:")
source.via(flow).runWith(Sink.seq)
}
@ -87,31 +87,29 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
val futureRequests = for {
session <- login()
map <- getWorkoutsMap()
pairs = workouts.flatMap { wo =>
map.filter { case (name, _) => name == wo }
}
} yield {
log.info("\nDeleting workouts:")
pairs.map {
case (workout, id) =>
val label = s"$workout -> $id"
label -> Post(s"https://connect.garmin.com/modern/proxy/workout-service/workout/$id")
for {
workout <- workouts
ids = map.getOrElse(workout, Seq.empty)
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"))
:+ RawHeader("X-HTTP-Method-Override", "DELETE")))
}
}
val source = Source.fromFuture(futureRequests).flatMapConcat(Source(_))
.throttle(1, 1.second, 1, ThrottleMode.shaping)
.mapAsync(1) {
case (label, req) =>
Http().singleRequest(req).withoutBody.map { res =>
if (res.status == NoContent) log.info(s" $label")
else {
log.error(s" Cannot delete workout: $label")
log.debug(s" Response: $res")
}
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)
@ -141,10 +139,10 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
}
/**
* Retrieves workout mapping: name -> id @ GarminConnect
* Retrieves workout mapping: name -> Seq[id] @ GarminConnect
* @return
*/
private def getWorkoutsMap(): Future[Seq[(String, Long)]] = {
private def getWorkoutsMap(): Future[Map[String, Seq[Long]]] = {
val source = Source.fromFuture(login()).flatMapConcat { session =>
val req = Get("https://connect.garmin.com/modern/proxy/workout-service/workouts?start=1&limit=9999&myWorkoutsOnly=true&sharedWorkoutsOnly=false")
.withHeaders(session.headers
@ -154,9 +152,14 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
Http().singleRequest(req).flatMap { res =>
if (res.status == OK)
res.body.map { json =>
Json.parse(json).asOpt[Seq[JsObject]].map { arr =>
val x = Json.parse(json).asOpt[Seq[JsObject]].map { arr =>
arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long])
}.getOrElse(Seq.empty)
x.groupBy {
case (name, _) => name
}.map {
case (a, b) => a -> b.map(_._2)
}
}
else {
log.debug(s"Cannot retrieve workout list, response: $res")

@ -59,7 +59,7 @@ object Main extends App {
opt[String]('p', "password").action((x, c) => c.copy(password = x)).text("Password to login to Garmin Connect")
opt[Unit]('d', "delete").action((_, c) => c.copy(delete = true)).text("Delete all existing workouts with same names as the ones that are going to be imported.")
opt[Unit]('x', "delete").action((_, c) => c.copy(delete = true)).text("Delete all existing workouts with same names as the ones that are going to be imported.")
help("help").text("prints this usage text")
@ -73,6 +73,8 @@ object Main extends App {
action((_, c) => c.copy(mode = 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 = 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" +
@ -82,9 +84,15 @@ object Main extends App {
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.start.isEqual(LocalDate.MIN) && c.end.isEqual(LocalDate.MIN)) failure("Either start or end date must be entered!")
if (c.mode == 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("")
override def showUsageOnError = true
}

@ -0,0 +1 @@
version in ThisBuild := "0.1-SNAPSHOT"
Loading…
Cancel
Save