Garmin connect workout deletion, definition and scheduling through Command line

master
mgifos 8 years ago
parent cb59f52c26
commit 2481dc9952
  1. 5
      build.sbt
  2. 12
      src/main/resources/logback.xml
  3. 264
      src/main/scala/com.github.mgifos.workouts/GarminConnect.scala
  4. 144
      src/main/scala/com.github.mgifos.workouts/Main.scala
  5. 7
      src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala

@ -5,7 +5,12 @@ version := "0.1"
scalaVersion := "2.12.4" scalaVersion := "2.12.4"
libraryDependencies ++= Seq( 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.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.play" %% "play-json" % "2.6.9",
"com.typesafe.scala-logging" %% "scala-logging" % "3.7.2",
"org.scalatest" %% "scalatest" % "3.0.5" % "test" "org.scalatest" %% "scalatest" % "3.0.5" % "test"
) )

@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>

@ -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]
}
}
}
}
}

@ -3,21 +3,151 @@ package com.github.mgifos.workouts
import java.nio.file.{ Files, Paths } import java.nio.file.{ Files, Paths }
import java.time.LocalDate 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.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 { 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.")
val wp = new WeeklyPlan(csvBytes) help("help").text("prints this usage text")
println(wp.workouts) note("")
val x = LocalDate.now() arg[String]("<file>").required().action((x, c) => c.copy(csv = x)).text("File with a weekly based plan in CSV format")
val it = wp.get.zipWithIndex.map { note("\n")
case (maybeWorkout, i) => x.plusDays(i) -> maybeWorkout
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.")
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))
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)
} }

@ -34,11 +34,12 @@ class WeeklyPlan(csv: Array[Byte]) {
def workouts: Seq[WorkoutDef] = onlyDefs(processed) 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 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)) private def isAValidWeek(w: Seq[String]) = w.headOption.exists(no => no.trim.nonEmpty && no.forall(_.isDigit))

Loading…
Cancel
Save