From bcb859fbc1f13fcb0c53985103e1335fdcd25be3 Mon Sep 17 00:00:00 2001 From: duester Date: Sat, 5 Jun 2021 18:00:30 +0300 Subject: [PATCH 1/3] Add CalendarCommand (#11) --- application.conf | 15 ++ build.sbt | 45 +++--- .../calendar/google/credentials.json | 12 ++ .../command/CalendarCommand.scala | 140 ++++++++++++++++++ .../scala/commandcenter/command/Command.scala | 1 + .../commandcenter/command/CommandType.scala | 1 + .../calendar/CalendarCommandLineParser.scala | 72 +++++++++ .../calendar/CalendarFreeTextParser.scala | 48 ++++++ .../command/calendar/Client.scala | 58 ++++++++ .../command/calendar/ClientType.scala | 11 ++ .../command/calendar/GoogleClient.scala | 113 ++++++++++++++ 11 files changed, 495 insertions(+), 21 deletions(-) create mode 100644 core/src/main/resources/calendar/google/credentials.json create mode 100644 core/src/main/scala/commandcenter/command/CalendarCommand.scala create mode 100644 core/src/main/scala/commandcenter/command/calendar/CalendarCommandLineParser.scala create mode 100644 core/src/main/scala/commandcenter/command/calendar/CalendarFreeTextParser.scala create mode 100644 core/src/main/scala/commandcenter/command/calendar/Client.scala create mode 100644 core/src/main/scala/commandcenter/command/calendar/ClientType.scala create mode 100644 core/src/main/scala/commandcenter/command/calendar/GoogleClient.scala diff --git a/application.conf b/application.conf index 43f18fd9..f736a014 100755 --- a/application.conf +++ b/application.conf @@ -135,6 +135,21 @@ commands: [ {type: "SearchMavenCommand"} {type: "HoogleCommand"} {type: "CalculatorCommand"} + { + type: "CalendarCommand" + client: { + type: "Google" + calendarId: "xyz@gmail.com" + } + formats: { + dateFormat: "dd.MM.yyyy" + timeFormat: "HH:mm" + dayOffsets: { + today: 0 + tomorrow: 1 + } + } + } ] aliases = { diff --git a/build.sbt b/build.sbt index 39045d7d..017f5302 100644 --- a/build.sbt +++ b/build.sbt @@ -48,27 +48,30 @@ lazy val root = project lazy val core = module("core") .settings( libraryDependencies ++= Seq( - "dev.zio" %% "zio" % Version.zio, - "dev.zio" %% "zio-streams" % Version.zio, - "dev.zio" %% "zio-process" % "0.4.0", - "dev.zio" %% "zio-logging" % "0.5.10", - "io.github.kitlangton" %% "zio-magic" % "0.3.2", - "io.circe" %% "circe-config" % "0.8.0", - "org.scala-lang" % "scala-reflect" % "2.13.6", - "io.circe" %% "circe-core" % Version.circe, - "io.circe" %% "circe-parser" % Version.circe, - "com.monovore" %% "decline" % "2.0.0", - "com.lihaoyi" %% "fansi" % "0.2.14", - "com.beachape" %% "enumeratum" % Version.enumeratum, - "com.beachape" %% "enumeratum-circe" % Version.enumeratum, - "com.softwaremill.sttp.client" %% "core" % Version.sttp, - "com.softwaremill.sttp.client" %% "circe" % Version.sttp, - "com.softwaremill.sttp.client" %% "httpclient-backend-zio" % Version.sttp, - "com.lihaoyi" %% "fastparse" % "2.3.2", - "org.typelevel" %% "spire" % "0.17.0", - "org.cache2k" % "cache2k-core" % "1.6.0.Final", - "net.java.dev.jna" % "jna" % Version.jna, - "net.java.dev.jna" % "jna-platform" % Version.jna + "dev.zio" %% "zio" % Version.zio, + "dev.zio" %% "zio-streams" % Version.zio, + "dev.zio" %% "zio-process" % "0.4.0", + "dev.zio" %% "zio-logging" % "0.5.10", + "io.github.kitlangton" %% "zio-magic" % "0.3.2", + "io.circe" %% "circe-config" % "0.8.0", + "org.scala-lang" % "scala-reflect" % "2.13.6", + "io.circe" %% "circe-core" % Version.circe, + "io.circe" %% "circe-parser" % Version.circe, + "com.monovore" %% "decline" % "2.0.0", + "com.lihaoyi" %% "fansi" % "0.2.14", + "com.beachape" %% "enumeratum" % Version.enumeratum, + "com.beachape" %% "enumeratum-circe" % Version.enumeratum, + "com.softwaremill.sttp.client" %% "core" % Version.sttp, + "com.softwaremill.sttp.client" %% "circe" % Version.sttp, + "com.softwaremill.sttp.client" %% "httpclient-backend-zio" % Version.sttp, + "com.lihaoyi" %% "fastparse" % "2.3.2", + "org.typelevel" %% "spire" % "0.17.0", + "org.cache2k" % "cache2k-core" % "1.6.0.Final", + "net.java.dev.jna" % "jna" % Version.jna, + "net.java.dev.jna" % "jna-platform" % Version.jna, + "com.google.apis" % "google-api-services-calendar" % "v3-rev20210215-1.31.0", + "com.google.oauth-client" % "google-oauth-client-jetty" % "1.31.2", + "com.google.auth" % "google-auth-library-oauth2-http" % "0.25.2" ), buildInfoKeys := Seq[BuildInfoKey](version, scalaVersion, sbtVersion), buildInfoPackage := "commandcenter" diff --git a/core/src/main/resources/calendar/google/credentials.json b/core/src/main/resources/calendar/google/credentials.json new file mode 100644 index 00000000..0232fe1b --- /dev/null +++ b/core/src/main/resources/calendar/google/credentials.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "commandcentercalendar", + "private_key_id": "3cca8a21871afb7ef67e0595e5cb40d00d4a7a83", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC4RfP2vPt+si0B\nRBVKxgNzbBiVq4sPGiNdkKh1VOWwk4eX8p5P6cn/s/eIOvTtCnbICBddOEjm59jH\nS6ajyLPf1QTMhoww9IPFzP4vJqQOQIbXWfAyndWs+OcY7jFNJFe+DOfmP+QdVWyF\n7t2DbttlWOCg7s8SOGYbtEdBqHPm90iDMSqQD2Yj+WHB/TcUeAuvn4ptjvCJBwCS\nrjmDyuuLXt9u+eT9qg3+5VTbBoXqjquLdaydVZP6nIQ1QCnHsLUmOSTTi4EFLuqt\nc7pD8Ff+1wc7u9vXZjDCIw+G1Ot+XYkH9X9a6GeXOpoYekdyx8IfbovRV8bga8dz\n3gGBvgMVAgMBAAECggEABEZc4gNBVX1QiPcOBED73svTGnd8YbphJYJ0hyTdH4Z5\nNZaMp4HMlaLv9DOagfxlX3ZzcpeIUf8BD332k58ei2vXmnGfw2KwwwlQLkKO3eTq\n5Qh7mc/c1llbAEAhNRglGTvOIJBfTpcwO60ua10TgS2+J7tSh3Obo1+bx9ynxohd\n/xY7debIsZRb7Y+I5Lazk0sFBFZrWAQK08nYpCp373LvPQ4+6vSgocL6WjXLbD+z\nYL8gI/bUkcbZUOHESumbWkWKyNTuDYsuGje1lv1P2TD8yMn2AbhxTbCfkdd5l0UO\nxbmGvpUTLX23DwzpUGZz+J6sUMhh2pQZq1hMzvenwwKBgQD+T9nr+WkfdVvbMao+\n1eOuM44aTjPY6Y6jrga/5AcK8H3HuMLOmPbpnLpzDocsxHXfqcypzD1Gc9K4P0bn\nX5ZhA9IFJZY1oNi7GVPYIA1fXLHGrF9QHhRNZiu1A6By4E5Iktyl0yD7jtDWsu9x\nNsStnXrKgr/ulSDd/30k9naeWwKBgQC5fxYDQnQkfNBKmO4LdU/F8TWl30wiy46j\nwx33pL//48Y3W2yTvlfgbJOew2qK8kM+SBJXMmZ5a4Me+26gxC0kifmka+R5zCot\nd44xkhvdBK0j4GhtUxWzIbfzRsjZxLbVFzjiK/UR2nyyolIv8PonS0CDCY88BMXG\n00phe/x/TwKBgFzO7VuVuMx3Ot+Cf3vQ+PdA71IgSgGWMqz/PI1Y/Uz7uRtjDQzy\ne+GDhfOpUKGAzPej8wHgfFgyuqrsxMZ5dtrO1x1zux61JYMaWiPchqTPoj07+Mi3\nQXeBmt/DhBGIVGld11JY+4dydjp0MLfjYeFuQDqZfsvl9omtzJDptR5TAoGAZgK+\nz1IXXw2I2s1Zc9Gy6i9pimvPif8Z1XNzIoJm2Emh80WC44k0+IWddR0QlZL/advm\nwi9EbZezhzFMuHrKPKLoOAThpB2kQFbUSuyICDcPJIC/zQd5EocDi3Us9Z2Z0nwv\n2ynDX2shUnez7Qt/9mYK90UlkSMqxNnjuNKfnD8CgYBCj0L6gL2HiO99s+ghPAek\n+Uys6o6lmN60BRyn36RspdEUvD+29WpVzvxBvVHpATqs2EN+rfLZz1fSkxcMgywX\ni2hlRKsX/PItBaXnSGSP1hq7N4wmiKhjJhv46YjpPCc5lUn4/ACOQpDXOvdIEPFf\nzN7UkG6p2OTqhOrecu8cNQ==\n-----END PRIVATE KEY-----\n", + "client_email": "cccapp@commandcentercalendar.iam.gserviceaccount.com", + "client_id": "104849388392349094923", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/cccapp%40commandcentercalendar.iam.gserviceaccount.com" +} \ No newline at end of file diff --git a/core/src/main/scala/commandcenter/command/CalendarCommand.scala b/core/src/main/scala/commandcenter/command/CalendarCommand.scala new file mode 100644 index 00000000..b11c3407 --- /dev/null +++ b/core/src/main/scala/commandcenter/command/CalendarCommand.scala @@ -0,0 +1,140 @@ +package commandcenter.command + +import com.monovore.decline +import com.typesafe.config.Config +import commandcenter.CCRuntime.Env +import commandcenter.CommandContext +import commandcenter.command.CalendarCommand.Formats +import commandcenter.command.calendar._ +import commandcenter.view.DefaultView +import fansi.Str +import io.circe +import zio.{ TaskManaged, UIO, ZIO, ZManaged } + +import java.time.format.DateTimeFormatter +import java.time.{ LocalDate, LocalDateTime, LocalTime, ZoneId } +import scala.util.Try + +final case class CalendarCommand(override val commandNames: List[String], client: Client, formats: Formats) + extends Command[Unit] { + val commandType: CommandType = CommandType.CalendarCommand + val title: String = "Calendar" + val freeTextParser: CalendarFreeTextParser = new CalendarFreeTextParser(formats) + val cliCommand: decline.Command[Product] = new CalendarCommandLineParser(formats).parseCommand + + override def preview(searchInput: SearchInput): ZIO[Env, CommandError, PreviewResults[Unit]] = + previewFreeText(searchInput) <> previewCommandLine(searchInput) + + private def previewFreeText(searchInput: SearchInput): ZIO[Any, CommandError, PreviewResults[Unit]] = + for { + request <- ZIO.fromOption(freeTextParser.parseText(searchInput.input)).orElseFail(CommandError.NotApplicable) + } yield previewInsertRequest(request, searchInput.context) + + private def previewCommandLine(searchInput: SearchInput): ZIO[Any, CommandError, PreviewResults[Unit]] = + for { + input <- ZIO.fromOption(searchInput.asArgs).orElseFail(CommandError.NotApplicable) + parsed = cliCommand.parse(input.args) + previewItem <- ZIO + .fromEither(parsed) + .foldM( + help => UIO(HelpMessage.formatted(help)), + { + case request: ListRequest => client.list(request).mapError(CommandError.UnexpectedException) + case request: InsertRequest => UIO(request) + } + ) + } yield previewItem match { + case ListResponse(events) => + PreviewResults.fromIterable( + events.map { event => + Preview.unit + .score(Scores.high(input.context)) + .view(DefaultView("Calendar event", event.toString(formats))) + } + ) + case request : InsertRequest => + previewInsertRequest(request, input.context) + case help: Str => + PreviewResults.one( + Preview.unit + .score(Scores.high(input.context)) + .view(DefaultView(title, help)) + ) + } + + private def previewInsertRequest(request: InsertRequest, context: CommandContext) = { + val run = for { + _ <- client.insert(request) + } yield () + PreviewResults.one( + Preview.unit + .onRun(run) + .score(Scores.high(context)) + .view(DefaultView("Add calendar event", request.event.toString(formats))) + ) + } +} + +object CalendarCommand extends CommandPlugin[CalendarCommand] { + + override def make(config: Config): TaskManaged[CalendarCommand] = + ZManaged.fromEither( + for { + commandNames <- config.get[Option[List[String]]]("commandNames") + client <- getClient(config.getConfig("client")) + formats <- getFormats(config.getConfig("formats")) + } yield CalendarCommand(commandNames.getOrElse(List("calendar", "cal")), client, formats) + ) + + private def getClient(config: Config): Either[Exception, Client] = + for { + clientTypeString <- config.get[String]("type") + clientType <- ClientType.withNameEither(clientTypeString) + client <- clientType match { + case ClientType.Google => getGoogleClient(config) + } + } yield client + + private def getGoogleClient(config: Config): Either[circe.Error, GoogleClient] = + for { + calendarId <- config.get[String]("calendarId") + } yield GoogleClient(calendarId) + + private def getFormats(config: Config): Either[circe.Error, Formats] = for { + dateFormat <- config.get[String]("dateFormat") + timeFormat <- config.get[String]("timeFormat") + dayOffsets <- config.get[Map[String, Int]]("dayOffsets") + } yield Formats(dateFormat, timeFormat, dayOffsets) + + final case class Formats(dateFormat: String, timeFormat: String, dayOffsets: Map[String, Int]) { + val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(dateFormat) + val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(timeFormat) + val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(s"$dateFormat $timeFormat") + + def parseDate(dateString: String): Option[LocalDate] = + dayOffsets.get(dateString.toLowerCase) match { + case Some(offset) => Some(getLocalDateByOffset(offset)) + case _ => Try(LocalDate.parse(dateString, dateFormatter)).toOption + } + + def parseTime(timeString: String): Option[LocalTime] = + Try(LocalTime.parse(timeString, timeFormatter)).toOption + + def parseDateTime(dateTimeString: String): Option[LocalDateTime] = + dayOffsets.find { case (key, _) => + dateTimeString.startsWith(key) + } match { + case Some((key, offset)) => + parseTime(dateTimeString.stripPrefix(s"$key ")) match { + case Some(time) => + val date = getLocalDateByOffset(offset) + Some(LocalDateTime.of(date, time)) + case _ => None + } + case _ => Try(LocalDateTime.parse(dateTimeString, dateTimeFormatter)).toOption + } + + private def getLocalDateByOffset(offset: Int): LocalDate = + LocalDate.now(ZoneId.systemDefault()).plusDays(offset) + } +} diff --git a/core/src/main/scala/commandcenter/command/Command.scala b/core/src/main/scala/commandcenter/command/Command.scala index 3a4075f8..c1a6b6f8 100644 --- a/core/src/main/scala/commandcenter/command/Command.scala +++ b/core/src/main/scala/commandcenter/command/Command.scala @@ -100,6 +100,7 @@ object Command { typeName <- Task(config.getString("type")).toManaged_ command <- CommandType.withNameOption(typeName).getOrElse(CommandType.External(typeName)) match { case CommandType.CalculatorCommand => CalculatorCommand.make(config) + case CommandType.CalendarCommand => CalendarCommand.make(config) case CommandType.DecodeBase64Command => DecodeBase64Command.make(config) case CommandType.DecodeUrlCommand => DecodeUrlCommand.make(config) case CommandType.EncodeBase64Command => EncodeBase64Command.make(config) diff --git a/core/src/main/scala/commandcenter/command/CommandType.scala b/core/src/main/scala/commandcenter/command/CommandType.scala index c9dbaa03..7816f9f3 100644 --- a/core/src/main/scala/commandcenter/command/CommandType.scala +++ b/core/src/main/scala/commandcenter/command/CommandType.scala @@ -6,6 +6,7 @@ sealed trait CommandType extends EnumEntry object CommandType extends Enum[CommandType] { case object CalculatorCommand extends CommandType + case object CalendarCommand extends CommandType case object DecodeBase64Command extends CommandType case object DecodeUrlCommand extends CommandType case object EncodeBase64Command extends CommandType diff --git a/core/src/main/scala/commandcenter/command/calendar/CalendarCommandLineParser.scala b/core/src/main/scala/commandcenter/command/calendar/CalendarCommandLineParser.scala new file mode 100644 index 00000000..a5f335a4 --- /dev/null +++ b/core/src/main/scala/commandcenter/command/calendar/CalendarCommandLineParser.scala @@ -0,0 +1,72 @@ +package commandcenter.command.calendar + +import cats.data.Validated +import cats.data.Validated.Valid +import cats.implicits.catsSyntaxTuple4Semigroupal +import com.monovore.decline +import com.monovore.decline.{ Command, Opts } +import commandcenter.command.CalendarCommand.Formats + +final class CalendarCommandLineParser(formats: Formats) { + def parseCommand: Command[Product] = { + val listMaxResults = Opts.argument[Int]("maxResults").withDefault(3) + val listCommand = decline.Command("list", "List next events on calendar")(listMaxResults.map(ListRequest)) + + val insertSummary = Opts.argument[String]("summary") + val insertDescription = Opts.option[String]("desc", "description").orNone + val insertLocation = Opts.option[String]("loc", "location", "l").orNone + val insertStartDate0 = Opts.option[String]("date", "start date", "d") + val insertStartTime0 = Opts.option[String]("time", "start time", "t").orNone + val insertEndDate0 = Opts.option[String]("enddate", "end date").orNone + val insertEndTime0 = Opts.option[String]("endtime", "end time").orNone + val insertDateTimes = { + val dateErrorMessage = (s: String) => { + val dayOffsets = formats.dayOffsets.keys.map(key => s"'$key'").mkString("[", ", ", "]") + s"$s date should have pattern ${formats.dateFormat} or be one of $dayOffsets" + } + val timeErrorMessage = (s: String) => s"$s time should have pattern ${formats.timeFormat}" + (insertStartDate0, insertStartTime0, insertEndDate0, insertEndTime0).tupled.mapValidated { + case (_, Some(_), Some(_), None) => + Validated.invalidNel("If you specify start time, you also have to specify end time") + case (_, _, None, Some(_)) => Validated.invalidNel("If you specify end time, you also have to specify end date") + case (_, None, _, Some(_)) => Validated.invalidNel("For all-day events you must not specify end time") + case (sd, st, ed, et) => + val sdv = formats.parseDate(sd) match { + case Some(dt) => Validated.valid(dt) + case _ => Validated.invalidNel(dateErrorMessage("start")) + } + val stv = st.map(formats.parseTime) match { + case Some(Some(t)) => Validated.valid(Some(t)) + case Some(_) => Validated.invalidNel(timeErrorMessage("start")) + case _ => Validated.valid(None) + } + val edv = ed.map(formats.parseDate) match { + case Some(Some(d)) => Validated.valid(Some(d)) + case Some(_) => Validated.invalidNel(dateErrorMessage("end")) + case _ => Validated.valid(None) + } + val etv = et.map(formats.parseTime) match { + case Some(Some(t)) => Validated.valid(Some(t)) + case Some(_) => Validated.invalidNel(timeErrorMessage("end")) + case _ => Validated.valid(None) + } + + (sdv, stv, edv, etv) match { + case (Valid(sd), Valid(Some(st)), Valid(Some(ed)), Valid(Some(et))) if sd.isEqual(ed) && et.isBefore(st) => + Validated.invalidNel("End time before start time") + case (Valid(sd), _, Valid(Some(ed)), _) if ed.isBefore(sd) => + Validated.invalidNel("End date before start date") + case _ => (sdv, stv, edv, etv).tupled + } + } + } + val insertCommand = decline.Command("insert", "Add event to calendar")( + (insertSummary, insertDescription, insertLocation, insertDateTimes).mapN { case (s, d, l, (sd, st, ed, et)) => + InsertRequest(Event(s, d, l, EventDate(sd, st), ed.map(EventDate(_, et)))) + } + ) + + val opts = Opts.subcommands(listCommand, insertCommand) + decline.Command("calendar", "Calendar commands")(opts) + } +} diff --git a/core/src/main/scala/commandcenter/command/calendar/CalendarFreeTextParser.scala b/core/src/main/scala/commandcenter/command/calendar/CalendarFreeTextParser.scala new file mode 100644 index 00000000..137c89a1 --- /dev/null +++ b/core/src/main/scala/commandcenter/command/calendar/CalendarFreeTextParser.scala @@ -0,0 +1,48 @@ +package commandcenter.command.calendar + +import commandcenter.command.CalendarCommand.Formats +import fastparse.Parsed.Success +import fastparse.ScalaWhitespace._ +import fastparse._ + +import java.time.{ LocalDate, ZoneId } + +final class CalendarFreeTextParser(formats: Formats) { + def parseText(input: String): Option[InsertRequest] = + parse(input, parseEvent(_)) match { + case Success(event, _) => Some(InsertRequest(event)) + case _ => None + } + + private def parseEvent[_: P]: P[Event] = + P(summary ~ "@" ~ dateTime ~ End).map { case (summary, dateTime) => + Event(summary, startDateTime = dateTime) + } + + private def summary[_: P]: P[String] = { + import fastparse.NoWhitespace._ + + P(!"@" ~ AnyChar).rep(1).!.map(_.trim) + } + + private def dateTime[_: P]: P[EventDate] = { + def dt = P(AnyChar.rep(1).!).map(_.trim) + + dt.flatMap { dateTimeString => + formats + .parseDateTime(dateTimeString) + .map(dateTime => Pass(EventDate(dateTime.toLocalDate, Some(dateTime.toLocalTime)))) + .orElse( + formats + .parseDate(dateTimeString) + .map(date => Pass(EventDate(date, None))) + ) + .orElse( + formats + .parseTime(dateTimeString) + .map(time => Pass(EventDate(LocalDate.now(ZoneId.systemDefault()), Some(time)))) + ) + .getOrElse(Fail) + } + } +} diff --git a/core/src/main/scala/commandcenter/command/calendar/Client.scala b/core/src/main/scala/commandcenter/command/calendar/Client.scala new file mode 100644 index 00000000..f0633788 --- /dev/null +++ b/core/src/main/scala/commandcenter/command/calendar/Client.scala @@ -0,0 +1,58 @@ +package commandcenter.command.calendar + +import commandcenter.command.CalendarCommand.Formats +import fansi.{ Color, Str } +import zio.Task + +import java.time.{ LocalDate, LocalTime } + +trait Client { + def list(request: ListRequest): Task[ListResponse] + + def insert(request: InsertRequest): Task[Unit] +} + +case class Event( + summary: String, + description: Option[String] = None, + location: Option[String] = None, + startDateTime: EventDate, + endDateTime: Option[EventDate] = None +) { + def toString(formats: Formats): String = { + def elide(text: String, maxLength: Int) = { + val ellipsis = "..." + if (text.length <= maxLength) text + else text.substring(0, maxLength - ellipsis.length) + ellipsis + } + def formatSummary: Str = summary + def formatDescription(description: String): Str = Color.Cyan("Description: ") ++ elide(description, 50) + def formatLocation(location: String): Str = Color.Cyan("Location: ") ++ location + def formatStartDateTime: Str = Color.Cyan("Start: ") ++ startDateTime.toString(formats) + def formatEndDateTime(endDateTime: EventDate): Str = Color.Cyan("End: ") ++ endDateTime.toString(formats) + + val lines = List( + Some(formatSummary), + description.map(formatDescription), + location.map(formatLocation), + Some(formatStartDateTime), + endDateTime.map(formatEndDateTime) + ) + lines.collect { case Some(value) => value }.mkString("\n") + } +} + +case class EventDate(date: LocalDate, time: Option[LocalTime]) { + def toString(formats: Formats): String = + time + .map(date.atTime) + .fold(date.format(formats.dateFormatter))(dt => + s"${dt.format(formats.dateFormatter)} ${dt.format(formats.timeFormatter)}" + ) +} + +case class ListRequest(maxResults: Int) + +case class ListResponse(events: List[Event]) + +case class InsertRequest(event: Event) diff --git a/core/src/main/scala/commandcenter/command/calendar/ClientType.scala b/core/src/main/scala/commandcenter/command/calendar/ClientType.scala new file mode 100644 index 00000000..f79db86a --- /dev/null +++ b/core/src/main/scala/commandcenter/command/calendar/ClientType.scala @@ -0,0 +1,11 @@ +package commandcenter.command.calendar + +import enumeratum.{ Enum, EnumEntry } + +sealed trait ClientType extends EnumEntry + +object ClientType extends Enum[ClientType] { + case object Google extends ClientType + + lazy val values: IndexedSeq[ClientType] = findValues +} diff --git a/core/src/main/scala/commandcenter/command/calendar/GoogleClient.scala b/core/src/main/scala/commandcenter/command/calendar/GoogleClient.scala new file mode 100644 index 00000000..bfdd5645 --- /dev/null +++ b/core/src/main/scala/commandcenter/command/calendar/GoogleClient.scala @@ -0,0 +1,113 @@ +package commandcenter.command.calendar + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.json.jackson2.JacksonFactory +import com.google.api.client.util.DateTime +import com.google.api.services.calendar.model.{ Event => GEvent, EventDateTime => GEventDateTime } +import com.google.api.services.calendar.{ Calendar, CalendarScopes } +import com.google.auth.Credentials +import com.google.auth.http.HttpCredentialsAdapter +import com.google.auth.oauth2.ServiceAccountCredentials +import commandcenter.command.calendar.GoogleClient.{ + eventDateToGoogleEventDateTime, + getCalendarService, + getCredentials, + googleEventDateTimeToEventDate +} +import zio.Task + +import java.time.ZoneId +import java.util.Date + +final case class GoogleClient(calendarId: String) extends Client { + import scala.jdk.CollectionConverters._ + + override def list(request: ListRequest): Task[ListResponse] = for { + credentials <- getCredentials + googleEvents <- Task( + getCalendarService(credentials) + .events() + .list(calendarId) + .setMaxResults(request.maxResults) + .setTimeMin(new DateTime(System.currentTimeMillis())) + .setOrderBy("startTime") + .setSingleEvents(true) + .execute() + ) + } yield { + val events = googleEvents.getItems.asScala.toList.map(googleEvent => + Event( + googleEvent.getSummary, + Option(googleEvent.getDescription), + Option(googleEvent.getLocation), + googleEventDateTimeToEventDate(googleEvent.getStart), + Some(googleEventDateTimeToEventDate(googleEvent.getEnd)) + ) + ) + ListResponse(events) + } + + override def insert(request: InsertRequest): Task[Unit] = { + val event = request.event + for { + credentials <- getCredentials + startDate = eventDateToGoogleEventDateTime(event.startDateTime) + endDate = event.endDateTime.map(eventDateToGoogleEventDateTime).getOrElse(startDate) + googleEvent = new GEvent() + .setSummary(event.summary) + .setDescription(event.description.orNull) + .setLocation(event.location.orNull) + .setStart(startDate) + .setEnd(endDate) + _ <- Task(getCalendarService(credentials).events().insert(calendarId, googleEvent).execute()) + } yield () + } +} + +object GoogleClient { + import scala.jdk.CollectionConverters._ + + private val credentialsFilePath = "calendar/google/credentials.json" + + private val scopes = List( + CalendarScopes.CALENDAR_READONLY, + CalendarScopes.CALENDAR_EVENTS + ).asJavaCollection + + private val credentials = Task( + ServiceAccountCredentials + .fromStream( + ClassLoader.getSystemResourceAsStream(credentialsFilePath) + ) + .createScoped(scopes) + ) + + def getCredentials: Task[Credentials] = + credentials.map { cred => + cred.refreshIfExpired() + cred + } + + def getCalendarService(credentials: Credentials): Calendar = + new Calendar.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + JacksonFactory.getDefaultInstance, + new HttpCredentialsAdapter(credentials) + ) + .setApplicationName("CommandCenter calendar") + .build() + + def googleEventDateTimeToEventDate(edt: GEventDateTime): EventDate = { + val googleDateTime = Option(edt.getDate).getOrElse(edt.getDateTime) + val zonedDateTime = new Date(googleDateTime.getValue).toInstant.atZone(ZoneId.systemDefault()) + val time = Option(edt.getDateTime).map(_ => zonedDateTime.toLocalTime) + EventDate(zonedDateTime.toLocalDate, time) + } + + def eventDateToGoogleEventDateTime(ed: EventDate): GEventDateTime = { + val edt = new GEventDateTime() + ed.time.fold( + edt.setDate(new DateTime(true, ed.date.toEpochDay * 86400000, 0)) + )(time => edt.setDateTime(new DateTime(Date.from(ed.date.atTime(time).atZone(ZoneId.systemDefault()).toInstant)))) + } +} From ecf4cbb263285e6f804eb35c191ccc300ffe5218 Mon Sep 17 00:00:00 2001 From: duester Date: Sat, 5 Jun 2021 22:21:35 +0300 Subject: [PATCH 2/3] Add CalendarCommand (#11) --- README.md | 78 ++++++++++++++++++ assets/configure-google-calendar.png | Bin 0 -> 28512 bytes assets/configure-google-calendar_ID.png | Bin 0 -> 4686 bytes .../command/CalendarCommand.scala | 2 +- 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 assets/configure-google-calendar.png create mode 100644 assets/configure-google-calendar_ID.png diff --git a/README.md b/README.md index 6c2d1100..a6593d4e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,84 @@ Type in `calculator functions` to see the full list of available operators and f Type in `calculator parameters` to see the full list of available configuration parameters for `application.conf`. +#### Calendar + +You can add events to a configured calendar and view oncoming events. Currently only Google calendars are supported. + +**Pre-configuring Google calendar** + +To be able to view and modify events, you have to allow the calendar to be accessed from within the application. +Open the settings for your calendar and add `cccapp@commandcentercalendar.iam.gserviceaccount.com` to the *Share with specific people* section. +Select the option "Make changes to events". + +![configuring Google calendar](assets/configure-google-calendar.png "Configuring Google calendar") + +**Configuring application** + +Then copy the calendar ID from the *Integrate calendar* section + +![Google calendar ID](assets/configure-google-calendar_ID.png "Google calendar ID") + +and paste it to the `application.conf`: + +```hocon +type: "CalendarCommand" +client: { + type: "Google" + calendarId: "xyz@gmail.com" +} +``` + +There you can also configure the date and time format (used both for parsing and displaying) as well as provide some date aliases: + +```hocon +formats: { + dateFormat: "dd.MM.yyyy" + timeFormat: "HH:mm" + dayOffsets: { + today: 0 + tomorrow: 1 + next week: 7 + } +} +``` + +**Listing oncoming events** + +Use the `list` subcommand to get `n` oncoming events (by default `n == 3`): + +`cal list 4` + +**Adding new event** + +For adding a new event there are two options: +- the simplest form: ` @ `: + +`John's welcome party @ 19:00` + +You can specify either date, or time, or both. Time without a date counts as a today event. + +- if you want to provide more details use the full form: + +`cal insert "An important event" --date tomorrow --time 18:00 --desc "A very important event, trust me" --loc Somewhere` + +Available options are: +- `--date` (or `-d`) - (start) date +- `--time` (or `-t`) - optional (start) time; omit for all-day events +- `--enddate` - optional end date; if omitted, equals to start date +- `--endtime` - optional end time; required if start time is given +- `--desc` - optional detailed description +- `--loc` - optional location + +Both for simple and full form you can use configured date aliases instead of concrete dates. For each specify a name and an offset (in days) from the current day. +You can also use localized names, e. g. +``` +dayOffsets: { + heute: 0 + morgen: 1 +} +``` + ## Installation At the moment there is no simple "1-step install". You need to compile and generate an executable yourself (or run diff --git a/assets/configure-google-calendar.png b/assets/configure-google-calendar.png new file mode 100644 index 0000000000000000000000000000000000000000..5a26e31db12ab0f8d6bb9e000bee01ba45ba91f8 GIT binary patch literal 28512 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 A`Tzg` literal 0 HcmV?d00001 diff --git a/core/src/main/scala/commandcenter/command/CalendarCommand.scala b/core/src/main/scala/commandcenter/command/CalendarCommand.scala index b11c3407..3690f1c3 100644 --- a/core/src/main/scala/commandcenter/command/CalendarCommand.scala +++ b/core/src/main/scala/commandcenter/command/CalendarCommand.scala @@ -44,7 +44,7 @@ final case class CalendarCommand(override val commandNames: List[String], client } ) } yield previewItem match { - case ListResponse(events) => + case ListResponse(events) => PreviewResults.fromIterable( events.map { event => Preview.unit From 722766fd052321d81658f835d8a35183f463e4cc Mon Sep 17 00:00:00 2001 From: duester Date: Sat, 5 Jun 2021 22:27:03 +0300 Subject: [PATCH 3/3] Add CalendarCommand (#11) --- .../main/scala/commandcenter/command/CalendarCommand.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/scala/commandcenter/command/CalendarCommand.scala b/core/src/main/scala/commandcenter/command/CalendarCommand.scala index 3690f1c3..81f3ef6e 100644 --- a/core/src/main/scala/commandcenter/command/CalendarCommand.scala +++ b/core/src/main/scala/commandcenter/command/CalendarCommand.scala @@ -52,9 +52,9 @@ final case class CalendarCommand(override val commandNames: List[String], client .view(DefaultView("Calendar event", event.toString(formats))) } ) - case request : InsertRequest => + case request: InsertRequest => previewInsertRequest(request, input.context) - case help: Str => + case help: Str => PreviewResults.one( Preview.unit .score(Scores.high(input.context))