diff --git a/build.sbt b/build.sbt
index 8c0ff25..83a2041 100644
--- a/build.sbt
+++ b/build.sbt
@@ -5,7 +5,12 @@ version := "0.1"
scalaVersion := "2.12.4"
libraryDependencies ++= Seq(
+ "ch.qos.logback" % "logback-classic" % "1.2.3",
+ "com.github.scopt" %% "scopt" % "3.7.0",
"com.github.tototoshi" %% "scala-csv" % "1.3.5",
+ "com.typesafe.akka" %% "akka-http" % "10.1.0",
+ "com.typesafe.akka" %% "akka-stream" % "2.5.11",
"com.typesafe.play" %% "play-json" % "2.6.9",
+ "com.typesafe.scala-logging" %% "scala-logging" % "3.7.2",
"org.scalatest" %% "scalatest" % "3.0.5" % "test"
)
\ No newline at end of file
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
new file mode 100644
index 0000000..a724795
--- /dev/null
+++ b/src/main/resources/logback.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ %msg%n
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala
new file mode 100644
index 0000000..baa4030
--- /dev/null
+++ b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala
@@ -0,0 +1,264 @@
+package com.github.mgifos.workouts
+
+import java.time.LocalDate
+
+import akka.actor.{ Actor, ActorRef, ActorSystem, Props }
+import akka.http.scaladsl.Http
+import akka.http.scaladsl.client.RequestBuilding._
+import akka.http.scaladsl.model.ContentTypes._
+import akka.http.scaladsl.model.HttpMethods._
+import akka.http.scaladsl.model.StatusCodes._
+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.{ Materializer, ThrottleMode }
+import akka.stream.scaladsl.{ Flow, Sink, Source }
+import akka.util.Timeout
+import com.github.mgifos.workouts.model.WorkoutDef
+import com.typesafe.scalalogging.Logger
+import play.api.libs.json.{ JsObject, Json }
+
+import scala.collection.immutable.{ Map, Seq }
+import scala.concurrent.duration._
+import scala.concurrent.{ ExecutionContext, Future }
+import scala.util.Failure
+
+case class GarminWorkout(name: String, id: Long)
+
+class GarminConnect(email: String, password: String)(implicit system: ActorSystem, executionContext: ExecutionContext, mat: Materializer) {
+
+ case class Session(username: String, headers: Seq[HttpHeader])
+
+ case class Login(forceNewSession: Boolean)
+
+ private val log = Logger(getClass)
+
+ /**
+ * Creates workout definitions
+ *
+ * @param workouts
+ * @return
+ */
+ def createWorkouts(workouts: Seq[WorkoutDef]): Future[Seq[GarminWorkout]] = {
+
+ val source = Source.fromFuture(login()).flatMapConcat { session =>
+ 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
+ :+ Referer("https://connect.garmin.com/modern/workout/create/running")
+ :+ RawHeader("NK", "NT"))
+ log.debug(s"Sending req: ${req.httpMessage}")
+ workout.name -> req
+ })
+ }
+
+ val flow = Flow[(String, HttpRequest)]
+ .throttle(1, 1.second, 1, ThrottleMode.shaping)
+ .mapAsync(1) {
+ case (workout, req) =>
+ for {
+ res <- Http().singleRequest(req).andThen {
+ case util.Success(ok) =>
+ log.debug(s"response is ok: $ok")
+ case Failure(ex) =>
+ log.error("Ups", ex)
+ }
+ if res.status == OK
+ json <- res.entity.toStrict(10.seconds).map(_.data.utf8String)
+ } yield {
+ log.info(s" '$workout'")
+ GarminWorkout(workout, Json.parse(json).\("workoutId").as[Long])
+ }
+ }
+
+ log.info("\nCreating workouts:")
+ source.via(flow).runWith(Sink.seq)
+ }
+
+ /**
+ * Deletes workouts with provided names
+ *
+ * @param workouts Workout names
+ * @return Count of deleted items
+ */
+ def deleteWorkouts(workouts: Seq[String]): Future[Int] = {
+
+ val futureRequests = for {
+ session <- login()
+ map <- getWorkoutsMap()
+ _ = log.debug(s"MAP: $map")
+ pairs = workouts.flatMap { wo =>
+ map.filter { case (name, _) => name == wo }
+ }
+ _ = log.debug(s"PAIRS: $pairs")
+ } 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"))
+ }
+ }
+ val source = Source.fromFuture(futureRequests).flatMapConcat(Source(_))
+ .throttle(1, 1.second, 1, ThrottleMode.shaping)
+ .mapAsync(1) {
+ case (label, req) =>
+ log.debug(s" Delete request: $req")
+ Http().singleRequest(req).map { res =>
+ res.discardEntityBytes()
+ if (res.status == NoContent) log.info(s" $label")
+ else log.error(s" Cannot delete workout: $label")
+ }
+ }
+ source.runWith(Sink.seq).map(_.length)
+ }
+
+ def schedule(spec: Seq[(LocalDate, GarminWorkout)]): Future[Int] = {
+ log.debug(s" Scheduling spec: ${spec.mkString("\n")}")
+ Source.fromFuture(login(forceNewSession = true)).flatMapConcat { session =>
+ log.info("\nScheduling:")
+ Source(spec).map {
+ case (date, gw) =>
+ log.debug(s"Making $date -> $gw")
+ s"$date -> '${gw.name}'" -> Post(s"https://connect.garmin.com/modern/proxy/workout-service/schedule/${gw.id}")
+ .withHeaders(session.headers
+ :+ Referer("https://connect.garmin.com/modern/calendar")
+ :+ RawHeader("NK", "NT"))
+ .withEntity(HttpEntity(`application/json`, Json.obj("date" -> date.toString).toString))
+ }.throttle(1, 1.second, 1, ThrottleMode.shaping)
+ .mapAsync(1) {
+ case (label, req) =>
+ log.debug(s" Sending $req")
+ Http().singleRequest(req).map { res =>
+ log.debug(s" Received $res")
+ if (res.status == OK) log.info(s" $label")
+ else log.error(s" Cannot schedule: $label")
+ res.discardEntityBytes()
+ }
+ }
+ }.runWith(Sink.seq).map(_.length)
+ }
+
+ /**
+ * Retrieves workout mapping: name -> id @ GarminConnect
+ * @return
+ */
+ private def getWorkoutsMap(): Future[Seq[(String, 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
+ :+ Referer("https://connect.garmin.com/modern/workouts")
+ :+ RawHeader("NK", "NT"))
+ Source.fromFuture(
+ for {
+ res <- Http().singleRequest(req)
+ if res.status == OK
+ json <- res.entity.toStrict(2.seconds).map(_.data.utf8String)
+ } yield Json.parse(json).asOpt[Seq[JsObject]].map { arr =>
+ arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long])
+ }.getOrElse(Seq.empty))
+ }
+ source.runWith(Sink.head)
+ }
+
+ private lazy val loginActor: ActorRef = system.actorOf(Props(new LoginActor()))
+
+ private def login(forceNewSession: Boolean = false): Future[Session] = ask(loginActor, Login(forceNewSession))(Timeout(2.minutes)).mapTo[Session]
+
+ /**
+ * Holds and reloads session if neccessary
+ */
+ class LoginActor extends Actor {
+
+ private case class NewSession(session: Session)
+
+ var maybeSession: Option[Session] = None
+
+ override def receive = {
+
+ case Login(force) =>
+ val origin = sender()
+ maybeSession match {
+ case Some(s) if !force => origin ! s
+ case _ =>
+ login.andThen {
+ case util.Success(x) =>
+ origin ! x
+ self ! NewSession(x)
+ case Failure(_) =>
+ log.error("Failed to log in to Garmin Connect")
+ }
+ }
+
+ case NewSession(session) =>
+ if (maybeSession.isEmpty) log.info("Successfully logged in to Garmin Connect")
+ maybeSession = Option(session)
+ }
+
+ private def login: Future[Session] = {
+
+ def extractCookies(res: HttpResponse) = res.headers.collect { case x: `Set-Cookie` => x.cookie }.map(c => Cookie(c.name, c.value))
+
+ def redirectionLoop(count: Int, url: String, acc: Seq[Cookie]): Future[Seq[Cookie]] = {
+ Http().singleRequest {
+ HttpRequest(uri = Uri(url)).withHeaders(acc)
+ }.flatMap { res =>
+ res.discardEntityBytes()
+ val cookies = extractCookies(res)
+ res.headers.find(_.name() == "Location") match {
+ case Some(header) =>
+ if (count < 7) {
+ val path = header.value()
+ val base = path.split("/").take(3).mkString("/")
+ val nextUrl = if (path.startsWith("/")) base + path else path
+ redirectionLoop(count + 1, nextUrl, acc ++ cookies)
+ } else {
+ Future.successful(acc ++ cookies)
+ }
+ case None => Future.successful(acc ++ cookies)
+ }
+ }
+ }
+
+ val params = Map(
+ "service" -> "https://connect.garmin.com/post-auth/login",
+ "clientId" -> "GarminConnect",
+ "gauthHost" -> "https://sso.garmin.com/sso",
+ "consumeServiceTicket" -> "false")
+ for {
+ res1 <- Http().singleRequest(HttpRequest(uri = Uri("https://sso.garmin.com/sso/login").withQuery(Query(params))))
+ res2 <- Http().singleRequest(
+ HttpRequest(
+ POST,
+ Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)),
+ entity = FormData(Map(
+ "username" -> email,
+ "password" -> password,
+ "_eventId" -> "submit",
+ "embed" -> "true")).toEntity).withHeaders(extractCookies(res1)))
+ sessionCookies <- redirectionLoop(0, "https://connect.garmin.com/post-auth/login", extractCookies(res2))
+ username <- getUsername(sessionCookies)
+ } yield {
+ res1.discardEntityBytes()
+ res2.discardEntityBytes()
+ Session(username, sessionCookies)
+ }
+ }
+
+ private def getUsername(sessionCookies: Seq[HttpHeader]): Future[String] = {
+ val req = HttpRequest(GET, Uri("https://connect.garmin.com/user/username")).withHeaders(sessionCookies)
+ Http().singleRequest(req).flatMap { res =>
+ if (res.status != StatusCodes.OK) throw new Error("Login failed!")
+ res.entity.toStrict(2.seconds).map(_.data.utf8String).map { json =>
+ (Json.parse(json) \ "username").as[String]
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/scala/com.github.mgifos.workouts/Main.scala b/src/main/scala/com.github.mgifos.workouts/Main.scala
index 80c4eb8..696be61 100644
--- a/src/main/scala/com.github.mgifos.workouts/Main.scala
+++ b/src/main/scala/com.github.mgifos.workouts/Main.scala
@@ -3,21 +3,151 @@ package com.github.mgifos.workouts
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.typesafe.scalalogging.Logger
+import scopt.OptionParser
+
+import scala.collection.immutable.Seq
+import scala.concurrent.duration._
+import scala.concurrent.{ Await, ExecutionContextExecutor, Future }
+
+object Modes extends Enumeration {
+ type Mode = Value
+ val `import` = Value("import")
+ val schedule = Value("schedule")
+}
+
+case class Config(
+ mode: Modes.Mode = Modes.`import`,
+ csv: String = "",
+ delete: Boolean = false,
+ email: String = "",
+ password: String = "",
+ start: LocalDate = LocalDate.MIN,
+ end: LocalDate = LocalDate.MIN)
object Main extends App {
- val csvBytes = Files.readAllBytes(Paths.get("src/test/resources/ultra-80k-runnersworld.csv"))
+ implicit val system: ActorSystem = ActorSystem("quick-plan")
+ implicit val materializer: ActorMaterializer = ActorMaterializer()
+ implicit val executionContext: ExecutionContextExecutor = system.dispatcher
+
+ val log = Logger(getClass)
+
+ parser.parse(args, Config()) match {
+
+ case Some(config) =>
+ val worker = run(config).andThen {
+ case _ =>
+ shutdown()
+ log.info("Logged out and closed connection")
+ }
+ Await.result(worker, 10.minutes)
+ log.info("Bye")
+
+ case None => shutdown()
+ }
+
+ def parser = new OptionParser[Config]("quick-plan") {
+
+ head("\nquick-plan", "0.x\n")
+
+ opt[String]('e', "email").action((x, c) => c.copy(email = x)).text("E-mail 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.")
+
+ help("help").text("prints this usage text")
+
+ note("")
+
+ arg[String]("").required().action((x, c) => c.copy(csv = x)).text("File with a weekly based plan in CSV format")
- val wp = new WeeklyPlan(csvBytes)
+ note("\n")
- println(wp.workouts)
+ cmd("import").
+ 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.")
- val x = LocalDate.now()
+ 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" +
+ " properly. In case both are entered, start date has priority. All dates have to be entered in ISO date format" +
+ " e.g. '2018-03-24'.\n")
+ .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 =>
+ if (c.start.isEqual(LocalDate.MIN) && c.end.isEqual(LocalDate.MIN)) failure("Either start or end date must be entered!")
+ else success))
- val it = wp.get.zipWithIndex.map {
- case (maybeWorkout, i) => x.plusDays(i) -> maybeWorkout
+ override def showUsageOnError = true
}
- it.zipWithIndex.map { case (maybeScheduledWorkout, i) => s"day $i: $maybeScheduledWorkout" }.foreach(println)
+ def run(implicit config: Config): Future[Unit] = {
+
+ val console = System.console()
+
+ 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 {
+ print("Password: ")
+ new String(console.readPassword())
+ }
+
+ implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))
+
+ implicit val garmin: GarminConnect = new GarminConnect(email, password)
+
+ val workouts = plan.workouts.toIndexedSeq
+
+ for {
+ maybeDeleteMessage <- deleteWorkoutsTask(workouts.map(_.name))
+ garminWorkouts <- garmin.createWorkouts(workouts)
+ maybeScheduleMessage <- scheduleTask(garminWorkouts)
+ } yield {
+ log.info("\n Statistics:")
+ maybeDeleteMessage.foreach(msg => log.info(" " + msg))
+ log.info(s" ${garminWorkouts.length} workouts has been imported to Garmin Connect")
+ maybeScheduleMessage.foreach(msg => log.info(" " + msg))
+ }
+ }
+
+ /**
+ * Deletes existing workouts with the same names or not
+ */
+ private def deleteWorkoutsTask(workouts: Seq[String])(implicit config: Config, garmin: GarminConnect): Future[Option[String]] = {
+ if (config.delete)
+ garmin.deleteWorkouts(workouts).map(c => Some(s"$c workouts is deleted"))
+ else
+ Future.successful(None)
+ }
+
+ private def scheduleTask(workouts: Seq[GarminWorkout])(implicit config: Config, garmin: GarminConnect, plan: WeeklyPlan): Future[Option[String]] = {
+
+ if (config.mode == Modes.schedule) {
+
+ val start = (config.start, config.end) match {
+ case (_, end) if !end.isEqual(LocalDate.MIN) => end.minusDays(plan.get.length - 1)
+ case (from, _) => from
+ }
+
+ val woMap: Map[String, GarminWorkout] = Map(workouts.map(ga => ga.name -> ga): _*)
+ 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]
+ garmin.schedule(spec).map(c => Some(s"$c workouts is scheduled"))
+ } else
+ Future.successful(None)
+ }
+
+ private def shutdown() = Await.result(Http().shutdownAllConnectionPools().flatMap(_ => system.terminate()), 10.minutes)
+
}
diff --git a/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala
index 5e1b459..88aef36 100644
--- a/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala
+++ b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala
@@ -34,11 +34,12 @@ class WeeklyPlan(csv: Array[Byte]) {
def workouts: Seq[WorkoutDef] = onlyDefs(processed)
/**
- * @return optional workout refs & notes of this plan per day
+ * @return optional workout refs (defs included as refs)
*/
- def get: Seq[Option[Workout]] = processed.map {
+ def get: Seq[Option[WorkoutRef]] = processed.map {
case Some(x: WorkoutDef) => Some(x.toRef)
- case x => x
+ case Some(x: WorkoutRef) => Some(x)
+ case _ => None
}
private def isAValidWeek(w: Seq[String]) = w.headOption.exists(no => no.trim.nonEmpty && no.forall(_.isDigit))