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