From d1053e44dd66928280c4e15f00aa170532ad1b3c Mon Sep 17 00:00:00 2001 From: mgifos Date: Thu, 29 Mar 2018 08:50:23 +0200 Subject: [PATCH] Preparation for a release --- README.md | 13 +++-- build.sbt | 8 +++- project/plugins.sbt | 2 + .../GarminConnect.scala | 47 ++++++++++--------- .../com.github.mgifos.workouts/Main.scala | 12 ++++- version.sbt | 1 + 6 files changed, 55 insertions(+), 28 deletions(-) create mode 100644 version.sbt diff --git a/README.md b/README.md index d7411dc..4f1e978 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ An example of 2-weeks training plan, containing 2 workout definitions, 4 referen | 2 | run-fast| ``workout: long-15``
``- 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] -e, --email E-mail to login to Garmin Connect -p, --password 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 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`** := `
+` +**``** := `
+` **`
`** := `workout: ` diff --git a/build.sbt b/build.sbt index 83a2041..35d96fa 100644 --- a/build.sbt +++ b/build.sbt @@ -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( diff --git a/project/plugins.sbt b/project/plugins.sbt index 3a8b7aa..8e2e095 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1,3 @@ addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.2") + +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.3") diff --git a/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala index d968084..9229dbc 100644 --- a/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala +++ b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala @@ -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") - .withHeaders(session.headers - :+ Referer("https://connect.garmin.com/modern/workouts") - :+ RawHeader("NK", "NT") - :+ RawHeader("X-HTTP-Method-Override", "DELETE")) + 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"))) } } 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") diff --git a/src/main/scala/com.github.mgifos.workouts/Main.scala b/src/main/scala/com.github.mgifos.workouts/Main.scala index c3d37c1..c0c7b2a 100644 --- a/src/main/scala/com.github.mgifos.workouts/Main.scala +++ b/src/main/scala/com.github.mgifos.workouts/Main.scala @@ -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 } diff --git a/version.sbt b/version.sbt new file mode 100644 index 0000000..19138f1 --- /dev/null +++ b/version.sbt @@ -0,0 +1 @@ +version in ThisBuild := "0.1-SNAPSHOT" \ No newline at end of file