From 2481dc9952b739f89d8ee1d3d8b6defd7b89ce77 Mon Sep 17 00:00:00 2001 From: mgifos Date: Thu, 22 Mar 2018 22:27:57 +0100 Subject: [PATCH] Garmin connect workout deletion, definition and scheduling through Command line --- build.sbt | 5 + src/main/resources/logback.xml | 12 + .../GarminConnect.scala | 264 ++++++++++++++++++ .../com.github.mgifos.workouts/Main.scala | 144 +++++++++- .../model/WeeklyPlan.scala | 7 +- 5 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 src/main/resources/logback.xml create mode 100644 src/main/scala/com.github.mgifos.workouts/GarminConnect.scala 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))