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| | 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). 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 ## Command line options
``` ```
@ -37,7 +44,7 @@ Usage: quick-plan [import|schedule] [options] <file>
-e, --email <value> E-mail to login to Garmin Connect -e, --email <value> E-mail to login to Garmin Connect
-p, --password <value> Password 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 --help prints this usage text
<file> File with a weekly based plan in CSV format <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 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 ## Workout notation
The reserved keywords of the notation are: workout, warmup, cooldown, run, repeat, recover and lap-button. 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>` **`<header>`** := `workout: <name>`

@ -1,7 +1,13 @@
name := "workouts" name := "quick-plan"
version := "0.1" 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" scalaVersion := "2.12.4"
libraryDependencies ++= Seq( libraryDependencies ++= Seq(

@ -1 +1,3 @@
addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.2") 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]] = { def createWorkouts(workouts: Seq[WorkoutDef]): Future[Seq[GarminWorkout]] = {
val source = Source.fromFuture(login()).flatMapConcat { session => val source = Source.fromFuture(login()).flatMapConcat { session =>
log.info("\nCreating workouts:")
Source(workouts.map { workout => 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()))
@ -72,7 +73,6 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
} }
} }
log.info("\nCreating workouts:")
source.via(flow).runWith(Sink.seq) source.via(flow).runWith(Sink.seq)
} }
@ -87,31 +87,29 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
val futureRequests = for { val futureRequests = for {
session <- login() session <- login()
map <- getWorkoutsMap() map <- getWorkoutsMap()
pairs = workouts.flatMap { wo =>
map.filter { case (name, _) => name == wo }
}
} yield { } yield {
log.info("\nDeleting workouts:") log.info("\nDeleting workouts:")
pairs.map { for {
case (workout, id) => workout <- workouts
val label = s"$workout -> $id" ids = map.getOrElse(workout, Seq.empty)
label -> Post(s"https://connect.garmin.com/modern/proxy/workout-service/workout/$id") 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 .withHeaders(session.headers
:+ Referer("https://connect.garmin.com/modern/workouts") :+ Referer("https://connect.garmin.com/modern/workouts")
:+ RawHeader("NK", "NT") :+ RawHeader("NK", "NT")
:+ RawHeader("X-HTTP-Method-Override", "DELETE")) :+ 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) .throttle(1, 1.second, 1, ThrottleMode.shaping)
.mapAsync(1) { .mapAsync(1) {
case (label, req) => case (label, reqs) =>
Http().singleRequest(req).withoutBody.map { res => val statusesFut = Future.sequence(reqs.map(req => Http().singleRequest(req).withoutBody))
if (res.status == NoContent) log.info(s" $label") statusesFut.map { statuses =>
else { if (statuses.forall(_.status == NoContent)) log.info(s" $label")
log.error(s" Cannot delete workout: $label") else log.error(s" Cannot delete workout: $label")
log.debug(s" Response: $res")
}
} }
} }
source.runWith(Sink.seq).map(_.length) 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 * @return
*/ */
private def getWorkoutsMap(): Future[Seq[(String, Long)]] = { private def getWorkoutsMap(): Future[Map[String, Seq[Long]]] = {
val source = Source.fromFuture(login()).flatMapConcat { session => 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") 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
@ -154,9 +152,14 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste
Http().singleRequest(req).flatMap { res => 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 => val x = 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) }.getOrElse(Seq.empty)
x.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")

@ -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[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") help("help").text("prints this usage text")
@ -73,6 +73,8 @@ object Main extends App {
action((_, c) => c.copy(mode = Modes.`import`)).text( 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.") "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( 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" + "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" + " 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]('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(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)) 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 override def showUsageOnError = true
} }

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