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/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/assets/configure-google-calendar.png b/assets/configure-google-calendar.png new file mode 100644 index 00000000..5a26e31d Binary files /dev/null and b/assets/configure-google-calendar.png differ diff --git a/assets/configure-google-calendar_ID.png b/assets/configure-google-calendar_ID.png new file mode 100644 index 00000000..9d79d644 Binary files /dev/null and b/assets/configure-google-calendar_ID.png differ 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..81f3ef6e --- /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)))) + } +}