From 482148206ad9796ab55ead54867e2b781506f701 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:00:50 +1300 Subject: [PATCH 01/83] Initial commit with reactive-stocks --- .gitignore | 17 ++++ LICENSE | 13 +++ activator.properties | 4 + app/actors/StockActor.scala | 82 ++++++++++++++++++ app/actors/UserActor.java | 59 +++++++++++++ app/assets/javascripts/index.coffee | 100 ++++++++++++++++++++++ app/assets/stylesheets/main.less | 123 +++++++++++++++++++++++++++ app/controllers/Application.java | 54 ++++++++++++ app/controllers/StockSentiment.scala | 71 ++++++++++++++++ app/utils/FakeStockQuote.java | 15 ++++ app/utils/StockQuote.java | 5 ++ app/views/index.scala.html | 32 +++++++ build.sbt | 14 +++ conf/application.conf | 74 ++++++++++++++++ conf/routes | 11 +++ project/build.properties | 1 + project/plugins.sbt | 8 ++ public/images/buy.png | Bin 0 -> 42217 bytes public/images/favicon.png | Bin 0 -> 687 bytes public/images/hold.png | Bin 0 -> 25795 bytes public/images/sell.png | Bin 0 -> 49228 bytes test/FakeStockQuoteTest.java | 19 +++++ test/actors/ProbeWrapper.scala | 13 +++ test/actors/StockActorSpec.scala | 90 ++++++++++++++++++++ test/actors/StubOut.scala | 18 ++++ test/actors/TestkitExample.scala | 30 +++++++ test/actors/UserActorSpec.scala | 65 ++++++++++++++ tutorial/index.html | 109 ++++++++++++++++++++++++ 28 files changed, 1027 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 activator.properties create mode 100644 app/actors/StockActor.scala create mode 100644 app/actors/UserActor.java create mode 100644 app/assets/javascripts/index.coffee create mode 100644 app/assets/stylesheets/main.less create mode 100644 app/controllers/Application.java create mode 100644 app/controllers/StockSentiment.scala create mode 100644 app/utils/FakeStockQuote.java create mode 100644 app/utils/StockQuote.java create mode 100644 app/views/index.scala.html create mode 100644 build.sbt create mode 100644 conf/application.conf create mode 100644 conf/routes create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 public/images/buy.png create mode 100644 public/images/favicon.png create mode 100644 public/images/hold.png create mode 100644 public/images/sell.png create mode 100644 test/FakeStockQuoteTest.java create mode 100644 test/actors/ProbeWrapper.scala create mode 100644 test/actors/StockActorSpec.scala create mode 100644 test/actors/StubOut.scala create mode 100644 test/actors/TestkitExample.scala create mode 100644 test/actors/UserActorSpec.scala create mode 100644 tutorial/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..cf1bfceab --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +logs +project/project +project/target +target +tmp +.history +dist +/.idea +/*.iml +/out +/.idea_modules +/.classpath +/.project +/RUNNING_PID +/.settings +/project/*-shim.sbt +/activator-sbt-atmos-akka-shim.sbt diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..a02154466 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2013 Typesafe, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/activator.properties b/activator.properties new file mode 100644 index 000000000..1c1e66555 --- /dev/null +++ b/activator.properties @@ -0,0 +1,4 @@ +name=reactive-stocks +title=Reactive Stocks +description=The Reactive Stocks application uses Java, Scala, Play Framework, and Akka to illustrate a reactive app. The tutorial in this example will teach you the reactive basics including Reactive Composition and Reactive Push. +tags=Sample,java,scala,playframework,akka,reactive diff --git a/app/actors/StockActor.scala b/app/actors/StockActor.scala new file mode 100644 index 000000000..821eda6e2 --- /dev/null +++ b/app/actors/StockActor.scala @@ -0,0 +1,82 @@ +package actors + +import akka.actor.{Props, ActorRef, Actor} +import utils.{StockQuote, FakeStockQuote} +import java.util.Random +import scala.collection.immutable.{HashSet, Queue} +import scala.collection.JavaConverters._ +import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext.Implicits.global +import play.libs.Akka + +/** + * There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock + * values. Each StockActor updates a rolling dataset of randomly generated stock values. + */ + +class StockActor(symbol: String) extends Actor { + + lazy val stockQuote: StockQuote = new FakeStockQuote + + protected[this] var watchers: HashSet[ActorRef] = HashSet.empty[ActorRef] + + // A random data set which uses stockQuote.newPrice to get each data point + var stockHistory: Queue[java.lang.Double] = { + lazy val initialPrices: Stream[java.lang.Double] = (new Random().nextDouble * 800) #:: initialPrices.map(previous => stockQuote.newPrice(previous)) + initialPrices.take(50).to[Queue] + } + + // Fetch the latest stock value every 75ms + val stockTick = context.system.scheduler.schedule(Duration.Zero, 75.millis, self, FetchLatest) + + def receive = { + case FetchLatest => + // add a new stock price to the history and drop the oldest + val newPrice = stockQuote.newPrice(stockHistory.last.doubleValue()) + stockHistory = stockHistory.drop(1) :+ newPrice + // notify watchers + watchers.foreach(_ ! StockUpdate(symbol, newPrice)) + case WatchStock(_) => + // send the stock history to the user + sender ! StockHistory(symbol, stockHistory.asJava) + // add the watcher to the list + watchers = watchers + sender + case UnwatchStock(_) => + watchers = watchers - sender + if (watchers.size == 0) { + stockTick.cancel() + context.stop(self) + } + } +} + +class StocksActor extends Actor { + def receive = { + case watchStock @ WatchStock(symbol) => + // get or create the StockActor for the symbol and forward this message + context.child(symbol).getOrElse { + context.actorOf(Props(new StockActor(symbol)), symbol) + } forward watchStock + case unwatchStock @ UnwatchStock(Some(symbol)) => + // if there is a StockActor for the symbol forward this message + context.child(symbol).foreach(_.forward(unwatchStock)) + case unwatchStock @ UnwatchStock(None) => + // if no symbol is specified, forward to everyone + context.children.foreach(_.forward(unwatchStock)) + } +} + +object StocksActor { + lazy val stocksActor: ActorRef = Akka.system.actorOf(Props(classOf[StocksActor])) +} + + +case object FetchLatest + +case class StockUpdate(symbol: String, price: Number) + +case class StockHistory(symbol: String, history: java.util.List[java.lang.Double]) + +case class WatchStock(symbol: String) + +case class UnwatchStock(symbol: Option[String]) \ No newline at end of file diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java new file mode 100644 index 000000000..34b610e9a --- /dev/null +++ b/app/actors/UserActor.java @@ -0,0 +1,59 @@ +package actors; + +import akka.actor.UntypedActor; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import play.Play; +import play.libs.Json; +import play.mvc.WebSocket; + +import java.util.List; + +/** + * The broker between the WebSocket and the StockActor(s). The UserActor holds the connection and sends serialized + * JSON data to the client. + */ + +public class UserActor extends UntypedActor { + + private final WebSocket.Out out; + + public UserActor(WebSocket.Out out) { + this.out = out; + + // watch the default stocks + List defaultStocks = Play.application().configuration().getStringList("default.stocks"); + + for (String stockSymbol : defaultStocks) { + StocksActor.stocksActor().tell(new WatchStock(stockSymbol), getSelf()); + } + } + + public void onReceive(Object message) { + if (message instanceof StockUpdate) { + // push the stock to the client + StockUpdate stockUpdate = (StockUpdate)message; + ObjectNode stockUpdateMessage = Json.newObject(); + stockUpdateMessage.put("type", "stockupdate"); + stockUpdateMessage.put("symbol", stockUpdate.symbol()); + stockUpdateMessage.put("price", stockUpdate.price().doubleValue()); + out.write(stockUpdateMessage); + } + else if (message instanceof StockHistory) { + // push the history to the client + StockHistory stockHistory = (StockHistory)message; + + ObjectNode stockUpdateMessage = Json.newObject(); + stockUpdateMessage.put("type", "stockhistory"); + stockUpdateMessage.put("symbol", stockHistory.symbol()); + + ArrayNode historyJson = stockUpdateMessage.putArray("history"); + for (Object price : stockHistory.history()) { + historyJson.add(((Number)price).doubleValue()); + } + + out.write(stockUpdateMessage); + } + } +} diff --git a/app/assets/javascripts/index.coffee b/app/assets/javascripts/index.coffee new file mode 100644 index 000000000..b5fd83049 --- /dev/null +++ b/app/assets/javascripts/index.coffee @@ -0,0 +1,100 @@ +$ -> + ws = new WebSocket $("body").data("ws-url") + ws.onmessage = (event) -> + message = JSON.parse event.data + switch message.type + when "stockhistory" + populateStockHistory(message) + when "stockupdate" + updateStockChart(message) + else + console.log(message) + + $("#addsymbolform").submit (event) -> + event.preventDefault() + # send the message to watch the stock + ws.send(JSON.stringify({symbol: $("#addsymboltext").val()})) + # reset the form + $("#addsymboltext").val("") + +getPricesFromArray = (data) -> + (v[1] for v in data) + +getChartArray = (data) -> + ([i, v] for v, i in data) + +getChartOptions = (data) -> + series: + shadowSize: 0 + yaxis: + min: getAxisMin(data) + max: getAxisMax(data) + xaxis: + show: false + +getAxisMin = (data) -> + Math.min.apply(Math, data) * 0.9 + +getAxisMax = (data) -> + Math.max.apply(Math, data) * 1.1 + +populateStockHistory = (message) -> + chart = $("
").addClass("chart").prop("id", message.symbol) + chartHolder = $("
").addClass("chart-holder").append(chart) + chartHolder.append($("

").text("values are simulated")) + detailsHolder = $("

").addClass("details-holder") + flipper = $("
").addClass("flipper").append(chartHolder).append(detailsHolder).attr("data-content", message.symbol) + flipContainer = $("
").addClass("flip-container").append(flipper).click (event) -> + handleFlip($(this)) + $("#stocks").prepend(flipContainer) + plot = chart.plot([getChartArray(message.history)], getChartOptions(message.history)).data("plot") + +updateStockChart = (message) -> + if ($("#" + message.symbol).size() > 0) + plot = $("#" + message.symbol).data("plot") + data = getPricesFromArray(plot.getData()[0].data) + data.shift() + data.push(message.price) + plot.setData([getChartArray(data)]) + # update the yaxes if either the min or max is now out of the acceptable range + yaxes = plot.getOptions().yaxes[0] + if ((getAxisMin(data) < yaxes.min) || (getAxisMax(data) > yaxes.max)) + # reseting yaxes + yaxes.min = getAxisMin(data) + yaxes.max = getAxisMax(data) + plot.setupGrid() + # redraw the chart + plot.draw() + +handleFlip = (container) -> + if (container.hasClass("flipped")) + container.removeClass("flipped") + container.find(".details-holder").empty() + else + container.addClass("flipped") + # fetch stock details and tweet + $.ajax + url: "/sentiment/" + container.children(".flipper").attr("data-content") + dataType: "json" + context: container + success: (data) -> + detailsHolder = $(this).find(".details-holder") + detailsHolder.empty() + switch data.label + when "pos" + detailsHolder.append($("

").text("The tweets say BUY!")) + detailsHolder.append($("").attr("src", "/assets/images/buy.png")) + when "neg" + detailsHolder.append($("

").text("The tweets say SELL!")) + detailsHolder.append($("").attr("src", "/assets/images/sell.png")) + else + detailsHolder.append($("

").text("The tweets say HOLD!")) + detailsHolder.append($("").attr("src", "/assets/images/hold.png")) + error: (jqXHR, textStatus, error) -> + detailsHolder = $(this).find(".details-holder") + detailsHolder.empty() + detailsHolder.append($("

").text("Error: " + JSON.parse(jqXHR.responseText).error)) + # display loading info + detailsHolder = container.find(".details-holder") + detailsHolder.append($("

").text("Determing whether you should buy or sell based on the sentiment of recent tweets...")) + detailsHolder.append($("
").addClass("progress progress-striped active").append($("
").addClass("bar").css("width", "100%"))) \ No newline at end of file diff --git a/app/assets/stylesheets/main.less b/app/assets/stylesheets/main.less new file mode 100644 index 000000000..667977d9c --- /dev/null +++ b/app/assets/stylesheets/main.less @@ -0,0 +1,123 @@ +.perspective (@value) { + -webkit-perspective: @value; + -moz-perspective: @value; + perspective: @value; +} + +.transform (@value) { + -webkit-transform: rotateY(@value); + -moz-transform: rotateY(@value); + transform: rotateY(@value); +} + +.border-radius (@value) { + -webkit-border-radius: @value; + -moz-border-radius: @value; + border-radius: @value; +} + + +body { + margin-top: 50px; +} + +.flip-container { + .perspective(1000); + margin-bottom: 20px; + &:hover .flipper { + .transform(10deg); + } + &.flipped .flipper { + .transform(180deg); + } +} + +.flipper { + height: 250px; + + background-color: #fafafa; + border: 1px solid #ddd; + + .border-radius(4px); + + cursor: hand; + cursor: pointer; + + -webkit-transition: 0.6s; + -moz-transition: 0.6s; + transition: 0.6s; + + -webkit-transform-style: preserve-3d; + -moz-transform-style: preserve-3d; + transform-style: preserve-3d; + + &:after { + content: attr(data-content); + position: absolute; + top: -1px; + left: -1px; + padding: 3px 7px; + font-size: 12px; + font-weight: bold; + background-color: #ffffff; + border: 1px solid #ddd; + color: #9da0a4; + + .border-radius(4px 0 4px 0); + } +} + +.chart-holder, .details-holder { + position: absolute; + width: 100%; + height: 250px; + top: 0px; + left: 0px; + + -webkit-backface-visibility: hidden; + -moz-backface-visibility: hidden; + backface-visibility: hidden; +} + +.chart-holder { + z-index: 2; + & p { + position: absolute; + bottom: 7px; + right: 20px; + font-size: 10px; + color: #aaaaaa; + font-style: italic; + } +} + +.details-holder { + .transform(180deg); + text-align: center; + & h4 { + padding: 20px; + } + & .progress { + padding: 20px; + background: none; + border: none; + + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + } + & img { + height: 128px; + width: 128px; + } +} + +.chart { + position: relative; + width: 920px; + height: 210px; + margin-top: 30px; + margin-bottom: 10px; + margin-left: 10px; + margin-right: 10px; +} \ No newline at end of file diff --git a/app/controllers/Application.java b/app/controllers/Application.java new file mode 100644 index 000000000..72a70e51a --- /dev/null +++ b/app/controllers/Application.java @@ -0,0 +1,54 @@ +package controllers; + +import actors.*; +import akka.actor.*; +import akka.actor.ActorRef; +import com.fasterxml.jackson.databind.JsonNode; +import play.libs.Akka; +import play.libs.F; +import play.mvc.Controller; +import play.mvc.Result; +import play.mvc.WebSocket; +import scala.Option; + + +/** + * The main web controller that handles returning the index page, setting up a WebSocket, and watching a stock. + */ +public class Application extends Controller { + + public static Result index() { + return ok(views.html.index.render()); + } + + public static WebSocket ws() { + return new WebSocket() { + public void onReady(final WebSocket.In in, final WebSocket.Out out) { + // create a new UserActor and give it the default stocks to watch + final ActorRef userActor = Akka.system().actorOf(Props.create(UserActor.class, out)); + + // send all WebSocket message to the UserActor + in.onMessage(new F.Callback() { + @Override + public void invoke(JsonNode jsonNode) throws Throwable { + // parse the JSON into WatchStock + WatchStock watchStock = new WatchStock(jsonNode.get("symbol").textValue()); + // send the watchStock message to the StocksActor + StocksActor.stocksActor().tell(watchStock, userActor); + } + }); + + // on close, tell the userActor to shutdown + in.onClose(new F.Callback0() { + @Override + public void invoke() throws Throwable { + final Option none = Option.empty(); + StocksActor.stocksActor().tell(new UnwatchStock(none), userActor); + Akka.system().stop(userActor); + } + }); + } + }; + } + +} diff --git a/app/controllers/StockSentiment.scala b/app/controllers/StockSentiment.scala new file mode 100644 index 000000000..08fe902e4 --- /dev/null +++ b/app/controllers/StockSentiment.scala @@ -0,0 +1,71 @@ +package controllers + +import scala.concurrent.ExecutionContext.Implicits.global +import play.api.mvc._ +import play.api.libs.ws.WS +import scala.concurrent.Future +import play.api.libs.json.{Json, JsValue} +import play.api.Play +import play.api.libs.ws.Response +import play.api.libs.json.JsString + +object StockSentiment extends Controller { + + case class Tweet(text: String) + + implicit val tweetReads = Json.reads[Tweet] + + def getTextSentiment(text: String): Future[Response] = + WS.url(Play.current.configuration.getString("sentiment.url").get) post Map("text" -> Seq(text)) + + def getAverageSentiment(responses: Seq[Response], label: String): Double = responses.map { response => + (response.json \\ label).head.as[Double] + }.sum / responses.length.max(1) // avoid division by zero + + def loadSentimentFromTweets(json: JsValue): Seq[Future[Response]] = + (json \ "statuses").as[Seq[Tweet]] map (tweet => getTextSentiment(tweet.text)) + + def getTweets(symbol:String): Future[Response] = { + WS.url(Play.current.configuration.getString("tweet.url").get.format(symbol)).get.withFilter { response => + response.status == OK + } + } + + + def sentimentJson(sentiments: Seq[Response]) = { + val neg = getAverageSentiment(sentiments, "neg") + val neutral = getAverageSentiment(sentiments, "neutral") + val pos = getAverageSentiment(sentiments, "pos") + + val response = Json.obj( + "probability" -> Json.obj( + "neg" -> neg, + "neutral" -> neutral, + "pos" -> pos + ) + ) + + val classification = + if (neutral > 0.5) + "neutral" + else if (neg > pos) + "neg" + else + "pos" + + response + ("label" -> JsString(classification)) + } + + def get(symbol: String): Action[AnyContent] = Action.async { + val futureStockSentiments: Future[SimpleResult] = for { + tweets <- getTweets(symbol) // get tweets that contain the stock symbol + futureSentiments = loadSentimentFromTweets(tweets.json) // queue web requests each tweets' sentiments + sentiments <- Future.sequence(futureSentiments) // when the sentiment responses arrive, set them + } yield Ok(sentimentJson(sentiments)) + + futureStockSentiments.recoverWith { + case nsee: NoSuchElementException => + Future(InternalServerError(Json.obj("error" -> JsString("Could not fetch the tweets")))) + } + } +} diff --git a/app/utils/FakeStockQuote.java b/app/utils/FakeStockQuote.java new file mode 100644 index 000000000..6f923f7c2 --- /dev/null +++ b/app/utils/FakeStockQuote.java @@ -0,0 +1,15 @@ +package utils; + +import java.util.Random; + +/** + * Creates a randomly generated price based on the previous price + */ +public class FakeStockQuote implements StockQuote { + + public Double newPrice(Double lastPrice) { + // todo: this trends towards zero + return lastPrice * (0.95 + (0.1 * new Random().nextDouble())); // lastPrice * (0.95 to 1.05) + } + +} diff --git a/app/utils/StockQuote.java b/app/utils/StockQuote.java new file mode 100644 index 000000000..bee8cb488 --- /dev/null +++ b/app/utils/StockQuote.java @@ -0,0 +1,5 @@ +package utils; + +public interface StockQuote { + public Double newPrice(Double lastPrice); +} diff --git a/app/views/index.scala.html b/app/views/index.scala.html new file mode 100644 index 000000000..d1b0995b8 --- /dev/null +++ b/app/views/index.scala.html @@ -0,0 +1,32 @@ + + +@import play.mvc.Http.Context.Implicit._ + + + + Reactive Stock News Dashboard + + + + + + + + + + +
+ +
+ + \ No newline at end of file diff --git a/build.sbt b/build.sbt new file mode 100644 index 000000000..6d21ca22c --- /dev/null +++ b/build.sbt @@ -0,0 +1,14 @@ +name := "reactive-stocks" + +version := "1.0-SNAPSHOT" + +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % "2.2.1", + "com.typesafe.akka" %% "akka-slf4j" % "2.2.1", + "org.webjars" %% "webjars-play" % "2.2.1", + "org.webjars" % "bootstrap" % "2.3.1", + "org.webjars" % "flot" % "0.8.0", + "com.typesafe.akka" %% "akka-testkit" % "2.2.1" % "test" +) + +play.Project.playScalaSettings diff --git a/conf/application.conf b/conf/application.conf new file mode 100644 index 000000000..32436152f --- /dev/null +++ b/conf/application.conf @@ -0,0 +1,74 @@ +# This is the main configuration file for the application. +# ~~~~~ + +# Secret key +# ~~~~~ +# The secret key is used to secure cryptographics functions. +# If you deploy your application to several instances be sure to use the same key! +application.secret="Yf]0bsdO2ckhJd]^sQ^IPISElBrfyCn;nnaX:N/=R1<" + +# The application languages +# ~~~~~ +application.langs="en" + +# Global object class +# ~~~~~ +# Define the Global object class for this application. +# Defaults to Global in the root package. +# application.global=my.Global + +# Router +# ~~~~~ +# Define the Router object to use for this application. +# This router will be looked up first when the application is starting up, +# so make sure this is the entry point. +# Furthermore, it's assumed your route file is named properly. +# So for an application router like `my.application.Router`, +# you may need to define a router file `conf/my.application.routes`. +# Default to Routes in the root package (and conf/routes) +# application.router=my.application.Routes + +# Database configuration +# ~~~~~ +# You can declare as many datasources as you want. +# By convention, the default datasource is named `default` +# +# db.default.driver=org.h2.Driver +# db.default.url="jdbc:h2:mem:play" +# db.default.user=sa +# db.default.password="" + +# Evolutions +# ~~~~~ +# You can disable evolutions if needed +# evolutionplugin=disabled + +# Logger +# ~~~~~ +# You can also configure logback (http://logback.qos.ch/), by providing a logger.xml file in the conf directory . + +# Root logger: +logger.root=ERROR + +# Logger used by the framework: +logger.play=INFO + +# Logger provided to your application: +logger.application=DEBUG + +# Uncomment this for the most verbose Akka debugging: +#akka { +# loglevel = "DEBUG" +# actor { +# debug { +# receive = on +# autoreceive = on +# lifecycle = on +# } +# } +#} + +default.stocks=["GOOG", "AAPL", "ORCL"] + +sentiment.url="http://text-processing.com/api/sentiment/" +tweet.url="http://twitter-search-proxy.herokuapp.com/search/tweets?q=%%24%s" \ No newline at end of file diff --git a/conf/routes b/conf/routes new file mode 100644 index 000000000..731bf1a07 --- /dev/null +++ b/conf/routes @@ -0,0 +1,11 @@ +# Routes +# This file defines all application routes (Higher priority routes first) +# ~~~~ + +GET / controllers.Application.index +GET /ws controllers.Application.ws +GET /sentiment/:symbol controllers.StockSentiment.get(symbol) + +# Map static resources from the /public folder to the /assets URL path +GET /assets/*file controllers.Assets.at(path="/public", file) +GET /webjars/*file controllers.WebJarAssets.at(file) \ No newline at end of file diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 000000000..0974fce44 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.0 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 000000000..ba7a57acd --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,8 @@ +// Comment to get more information during initialization +logLevel := Level.Warn + +// The Typesafe repository +resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" + +// Use the Play sbt plugin for Play projects +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.2.1") diff --git a/public/images/buy.png b/public/images/buy.png new file mode 100644 index 0000000000000000000000000000000000000000..ccb20e5818faebc5f2d760a9a878d13e5b10d1ee GIT binary patch literal 42217 zcmb4qWm}tFuyv3k#S6t9iWK+aT3kw7+zGD5oghVvI~0Ojk>Ku5k>Fa~-7P_0p7&hm z2b@oL?oXM_UNf_1?Ujfxs&ZIwN#6nh04xQ0nXdo8qsyci?RyvqF-_4W*Z$i^v<*iGL@lF2pT2CC^fO>FOfu zE+6quA%axF$K67%EV+)GN4RX(3JT`Q7v)J>-1z$~3>i%hfHlAGP*-|*$bNO0KH3JS zk_WK6FCS_A^J>!?chE^Xz#!W$i_=0=IQySV!V51^thV zUJ!u7?d($x2%cC)+0P5HaY=|$goy@dl2VAswC{FUwX%_EC2s20)-ZoqqeLp;aPsUe zQU3t*T$bODTxMQH#hdFq3=d6_>tt*-KHr=%21e(u$8zI#1 zq^MPR5G7=gX>rke4vGm64o!)eVD4^IMDMy8muT_V{3EdX)%=iLkEW=HzM{lBTT=UyIGdM=`gz%3c) zd=e+2(N8Ka<5T1ZRlL~NF)uf3&l?Tup2-FFNat^hjqa0i;-Zj0e~pWJQ$TK+Ikf$l(G@?R>;1 zU`)u9my!*t;iaPqp+*!v$0>e;&=>If-8GpL?*e25H_Yib3-CUCjU>eTAQK)jk+#Ao z=5>Gnc*>Mg5m7oPM#D@)tw=iQfTtejg3|gl=6%Z5wKChyoM%eQVW{u(_UB_fNXVz9 zRY==%7l9`<^NJek#Yt}VPpz3tI@8PJA#ke~gip!g1*L?IGL!=7bCLrY5|FLJvs=<+#r;Ad=_&gnlMK|!&_ zfAPF|2HbfR)3sR-WDXg>v;R$sCGl?i()XJY{6YeJ?oX(MCn3#;$aU@fvqVY4=rWcK zpiJVynda@}B{xfjwQuyF{x#lsC{6))x;pH9&Dj0bE7?-#TI#d$*VH2<{3`(pHE=g6 zoZAX17gLg-e|Bh)k<2S)*DF(oyq{Jrj07i4&@v<}VD0Gy)M$@uxLIcpFwFa2c^!_g zz@3nU#dC37`i7R)Y2#R9c=TO=Un(H)(nqA##rJVy>kRUVIy?#$TK$ z%gaHR!I0Nb*{|Kb*r}x-vKJS5Z?8<&q^Fs((*`bjnEDZCsME4w(B$iMV&~|0KQlXW zRIDoMd{H`E_M`!jCO|O`pW-CWCJCrpjHxZ45p)>xbuLOS4vq@zWz}*DUVCT+)!CyN z=Do&Q`3-gu`^?nu#I=zxLmTq}3pHK4sfl)6w2?kd=h~-KcLPYn+}7#7b<)l(pLoCwK9ju>#s3&}D35$H4(R zq5f!yRUMVo5?)A^fj_Vi^@G1~7**y)VQ!nFC+*C)_73T(>|r6^eH8AjKY6bo`yUWwU?D~ifd1-& zPZ52G&ava!&9s-9JDdF)=n~v6$})HX_v3JfPaQ62+gc|}0&ehsmDRsDwNDY^n(!x# zx<|w|-!}WCfmM*IyCaQ8sCIv%{O!?4`5-rObd;I?Cj1iblJlx+O%2MCsVCa*X>xLL z1vPkj0eib{3_p7>neU3>VSVWektC6pM=(?PX5mCzd(f;2#=a6!aZyDjv1Hx?!LvY} z_L!Ai_GAHUMWBa>?3_<2;id{f@@6{hls@a3`!}KLLZkEnpJL3XC~cLM$LnzZ!N>FO z#!g9JGA<%^xkw`T5uCc2e>N>X?5;nMW*Ut|k2HaDGRc_FPo2W&*ppk`Qg%ZHMW_r& z(gjtf+Zx^LUyeieQpMqS*s1{CZ&I;+wfBqg0m;a_Q(v(l_BqBWEDbihbM z(mX5qIB!q^h&G{2V&&xI$7JsIJoGyg?0A59~YZ@C{Th;9Y zBW2rt$Q6_Wbh>%P0xh=gz|INlp(7@yl*-^WpCkQMYau*-Rd@b2G&mCkTE?{#dQBKs zLdLzizVdT@a>xBt@Sd5YA2FAUjEqyl&>Y#SZ||@HoT2k}VC%~9DC&dUPuwjNOamSS z6Xj3*2LjC%#rp!)6@vp8RgdR`bg}spQg!uj0pBIU@me-Ce9>$LM%98m# z1(a=9mN3V0G^-wjM(+`0^C<9=nXVJqI4Z@jhs41zSG7?6r^o(1E$~QZ@JbW*GJGz_K@3 z#O>s>XaT2|ubSapTu+qOHi?H;<@LD8U14FT_qpTvb8*?P_RjzVz|ju7RDDHSo!OF= ztWN%iTGuajRL{?64n9a0uTvTz_I!Yovh0;G#hV)I`&@opdAr3rLPsM5beO;IUOpB4 zab|xE1e8yQl-7}zcD!rr`kCR7#-*#cH>@SNvm#H44>w%7|;|=rZZH+Q{)xr`5YYMiDMa^d=hi|xYDiums6bQSPo(Bb93`o zblFnneIiV$?Z2Lm^sxi0~Dh!?EwH5wLfY4878jzinB?2T5(L4ZGj((Onk+s8S2 zK5zrSX54~+#qPh}f*Oo$>GC#TPc4~b~=l4m;kkFyB5g+f`D>#5h^27LA6X3+ony!rudPQN=US(<;m(@Ds{ z2f>QcYrg-0K^HyH#1J}%xUdtAuEAZ--7HJ_YxNSh3*n*Xgzqm|jz0m&VATrJY^y#k zCTx*|RkbYE+7=TC2#AugbXZ%sFhCuM!A5fJ}9PvAvoEk~fVEUFhx z+B(rForgk)w;PAiT>caSy>C|IxIMF_>ld}=f#TxyTP2RLzk?5YksRk}UWfEQe)`>3 z7rtD_zU*AT_?hS4*`@4iA(ZF-6GP^t&fDly1`bJ#3+@@Q$E~}aAUsY%{K6CH-#NV- zwp3&LZ!N*af$8M)tfqNiX_5QQYqi7s!Yua&PElz}^G5pLNq<>L|Nb%hLo)r&?fYac z@13GZscE8@()Zb;kEV2iGk;4;BaAN@rNMdJe>7!oEGL&Aqmq+nvN>XRi?>Adhvpmlj{9{w4sY^~jOn8}KoWbWwyrk58tA~Z&eMsHCa(y0#%t}a?O^Z~ ziEF6KSp(?MH=n!S)cYAO@6>S~STx*(AHV-Y`IX>0Mr5@ilQ~<#hY=5pZ#7))iK=hyJ~LEQbOQ{gFU26SIQ z!?UMEG)MLHXas&rw4^eC4YKZ(F$Mi(F^{XlF^=OkU$T?4eqBkiT%PYh*MksY`SX^` z{}}~=9rCRD`q5zDYpGE|fg%rAhE-VQ@6M-H56UNbA673U_xkOIyPms!PEngrR}R)2+}3fU|+b&e*Kg^ zWTOVSWiJY8XBfaCSHV7{`kPofc<&BhNLjQ_VN0 zPmVr&uZ-jh|64`lyZDkvQx2v*Y)?xGS`lhKq->*ldjqp&6dGOxHg~cieA07$u?ixq zY>qW1BtBEbj7J;BB-<}zzLiURRvy4$Ad@Svp9JMk%qR|{{P!mde!ch6z5X4}ZqU)i0nSXPdGfCp8{8|liYd&jQYu7Fn z?!Ke!)jH)yhF)=g7}{&7&H3uq{r+`P4C7jYe-=`pr}GlmD#lg|Mglr#C{9tjqNqRo z{n`iouvaT8Di%|CgmGfBaD%*wR3QfInFu@#oTKqYqw4h3z(74i)F117wg9o4-&A<7 zdAT{|^xk)|Oz5VzRfW1+tX@%gJWsHBoWe^#5ncrZ<|6p4t@acQxP_c$`T1{>MwM|{ zkn~k@7D3n8A^N&(bXpGtOxbi%7c7;OpvrB?{lR?*n|Sr?Ds|`_OI5cxL2tmoHNuk~ z%>71&6an99Fn#V{Y_Mn$bCW4VX5!uF9g#x|~)8_Plgj@7JP$6nR+HzJJVzs9Qu*D?eY<At4% z;*}~1A!uojgj<4t{L9kNac+Z6Lc$ns<{F)1suD6id6d8^KSQ$Gk+) z!?;XNwniefqKdjLz14-0SpTRCEX5|&fI)~oVFES+m_IbSA_Kp(wC8v_H#ix)tNHAO zOqW41=W0!K+JrRKNfCy$&xiHY*83v1AcAV^e6O74;Wt&T2y|z*(D1qt722VeK3y44S<}t*@Y8w0@IlG?@h(W6S6ACudVoy`8Dmr^^i3OAPZ3tF83F%k_AgN%k(qc}qNt&E5O-~2zf zgVCkhCxkP&z+4ncRirtk=ISeWsFi=^SlGmTA+}Z^7Hn&0Ozv|kWntr z`j!jHPAYQhNb_dz9|h}I4n4m6-!P2E*fCoz@D~vAnbtYnHu&|twJxLLAjh3TUggBS z;rYv>nFd1Be)qL6doC{)UezGdE^`Y*hCB^`0WNH;K{=#=m%AU$1>6d<_Aa_{Cj%1FNfhSYX}3OLR)qw- z|7`v)>Z1Ekz-NaP$bGNt2Xu{)9kj-S*}LY+R!R%eOx=5y>p5_ps~N0~FsET@PiUGvu7P06I+xwHgL9~ddbYR%gl!dEjyT#d zVBpm@Ci(3p*`JttsM)VVZ_WGVnF8Vvb0!G z&m8nQ=D*E2v22%<-~j3w6Prt$cgKGf&w~#=rf(9g4WFG-xG@x1>AD$JGrM|m+FOEy zAi7BBBQJ;21Ol{N`^A?V&@2U5d!68`IJpLYUKAAc{cE6VJza?A>$KZ7N{& zCdU6e%H+{M6;rb9{aof}R;O04a^N@(3xj}E&Kk5rY!v-SpUki3VUSC#}_|CbHp}FcL%&>HI3j55_KAq*~iTx(%Lc70$#k5L(+Y)m~aay|7aHn(OMo=Tc7g zlL#w!-;aw<7uQP%CgNu-=q2GhyNhu&1x(Rh7g`l~xMYcKZ3jf48>Cl_ey&yeqOX_8 ztTUD28_!88&c@{joT- zoL?^VL)ZQYSvHDk;OiMBt@){BWV?}W=u(o|nL6)R8r2Tw6BMkD;arruT=l{lPhFZP zaJRwvK#$d@8=nqAhsZ1d140y%6_ORAJjzk}c2r0dvfoIff32;j;rj!P9R%NFE&qfB zS(#oFcB;E9ynD=4uqrAzq=I56>7-v8qALs z*&V)6EYg}~5Ae1MPKL zsZuX>PLLK{A6D#}{-1C`+IaK2f6?J^d0h| zDBcjldA;>=D*3@5@2>XRKnjqg85SbpdR=+oaW@xg8~ePnX;cs_B5e&%7gRzWm55=Y zK}pobH{?6eKtlL$i7#Lxtovb7@2&N6vFx$tG1!hOE)gUY&@xa>;)w@o_LA+^AC6ocw zxcUX*#xS5_C|}dtryp{rHjA3id|e#%`D$0-%<~RR2XsU>A?-ruCAWHwP!*N+GyT3^ z=gAY~Bh&ex06^z`8C6b!Z}j|htU*+5=5NN+1(!Rmj=gPYz3{M;_U{Y-FY-p+=-ecd z6qWiS?LE5J{$9Q9OpHXRLB1IsLak6v#wQd>2F4PL%1<%A?mliE1;i0nxp;09QH)kK z&15cFbZIsxa`a?$Mj6)LXvh*@WWM%=%OEn-BY*xrlV$65yi+He{akNo+^qT1O`R=@ zDu;|WMs1}1;*ZONJL9@4ZcGyN1&&6*m{)%_Fg{77S^+++C@M1#EXRE740e*0VWnGN zC_{VOwmG*C%!Vo0@X}S+ExcC~^A$swSa31+zJqtZfM2Zb+Ad;(6g}iU&Cx2hh1QHn zAZPymT@?%3U;t#o=l)RfLrsW#4N`^YyDT&8EKk&oN#ukQ{N0X&WTRq})n3>-_grY| zBn7Lfy(bCFlM=C+0q3%KsaB(I=-t(k2qeRCPOcqwdj+xvr83}{|F<@zWn#QB3hMk3cT@4AZ#6%);Kxm+jb>tvTTxp<*o&6x`RlKJ7>hONcBW zp_C%)q7k$*CeF#f>PhxR*BI+#R(?JwC&QwgaM`MQ&TlMlZqA}Hs6NeDe&1UlA&XQX zf(r^ph{WQ(y(sq%SL|QHv^F78Z4+gK{G$1t(l(TEM$#+Z6``LIyX?IA=&E_x=n2Vf zyA71RRyfvAX(wcE6u%f4E82M{moUb8Fl*TFrp$E@jQTd0m|1VeonmHs?sp?GQGPFb zM|}a!)}_wAgrZcWp^7{;IijO{=_gRk+Sg)Ae<~9WpDhLO6q)uT|0BjMx0eMkPK&r- zR@55Zg+TT1JH2hKU-e6-xd>jeT@mB)ts=AZPN7W%2ov&szZxhYPkv}ml}$F4qw<3S zT}IPgS*$;NSorR-irS`?z1sakik<#Ci@RFXcm3>$bVmoPS3P!Qi9J7vKtruYp_7aL z!oYw2!y9D0`aJZ}I>)XJinFfgQeo?wX4Kb%{YsBZZ?lZUw^{*fbg$4ir41@T9`no` zAQR>iaE~`3E%{$B0MD+F9PY25plZ+2c4y|;)}(?AWA zPfzayzWjg-@UBMTS)y{aUrNPgW;!0Lpn8E`Po9KxW?TY~-0ZL5o<2jrLbn#ow~NIB zAa+}tT~L4*adS%T4O2=r1&j%=I-jdJKK!AhiU2l4s?HgTkb>~P=y=*nm7bMk2WWVu zu}JK2maq$Ot;<69^iY3&^rG;RC1v^zVxb%yuLipQfUzxz`oD{|A`dFyldE`-m(TUA zS=syeC(#*~K5mHLVo6}a^$1`(De)Hxv*vUt^~Vx^To(EcsqYoFT46gLiun20xt3rZ zRb~3NoFg9qy2Y%A9yA*JA6xL|aoG`6syn>gD7ra^er455vpea2jTxC<6BT1D|#+f*YeNNb$~SS>pZyyqU(4ZrOeVviVHy zRxJOs2bYDZMgdgr5DL#CU-mat4>`>~QbW5Xh{NwBN?F*}Jngc~n`Yl${?ol0>Jzx8} z=Y*}S_U7A7CYjfo6keonnkUN}nm&gG*wZF6EQfn0*KCQsE{K~6`pRs>x(qfBJ}aNWIZ*mwx0>-YmcW#S~RI+tNNzm3j$%-c{_ zQ&=iS25TaMryU34Db6IMW4ez!qi@cd=0^2CwVpW>@9ZvrKQ=@*mU5DNvAi!zsNXS+3gE8a~nZz_^L_=^6NIVknm{IB7tAI(cc!+d9Zj`Ak@)owsr zZW#XBc3j=Ij1RX181x7JDm+UkQA#|dqDwZ%^$o^QffjSSMqZl@Y?!LJ0B>@tuztT( zj^l&OOdI?p>{R$+pj{k8$`t79%t@ubqo~J)5TZa4Wpr;{@V3n7@6NZ9+3jL|1%#G? zy{q>Fu3V)N%qJfIKG#I_@;DYZSNGD(qj2$8;t$L}5+KXo=!NKUbUqLxgqPq&PkM68 zl?8vku1(H)=CgqO6@u=yUhBSSj^^0uz2BlyF*!22q6@iNq{LCA6kYw77950THW9Y- zUz#;jLou{CFxhb@<03yvD9SMJ4dpj^E}vhdlDuuR!=;Y=@Hw6UV^50?aM2+>-Rj^o z{Y-_DyY($hGr~J{`FUXJk_WY-1Bb))ZN^`XVNTS%Ha>kr(xi%AApovoB2<7?;CB9e^4YVkd>r9 z`LNeNh&n`8(|M?LxcSPpQN1D9&+*j+NQjVc$Hx(nzU8p?))5JaO$qG0vaB2+8fP7R zon39O#rM(`J!o;pNDJr9d%unG#w#i(M)#s(GvD7csDfJU{WefD)-f9G(#qev>!9|e zLBs}k3BI8JH+6{6A=8V+J%$RY8P!YC>kA!J6LLbmGWU=!IXaf&v0T16+=x@EEhBh-f|>&k<6qM)u^fqSQ%HfEgBi`^epf2JRMw<-?RY}90GkqbCP zX8qpoECN0T1(6WzdFY-mN=8Wy3-@WxOzcy^Tc%DL$^d0NIie*WT))s3Rb zQZMP~?mDJxc%x+i(oGgzDh)N3$lTnTpuoQqh4=4(^gm4>A-y`4*aHv(FqNe_owthZ zj4rPed;nBW+p*uKqFx1WaBNEI96m|Jtxv~QOg1>puKf)I;!hacCnUGr7IhO=93-2z zVy>)+NleVWrv^&iL0kqlR&T7`GEM$eF=C9~*qw zIY%`7+ILY)^`ah`D1Y5jx?hQH3K-Vwye(>ko{+*BXxR_l?F?2Lz}B}lh{BrlElsY-D$m!0YnYgZk6d=l2dM6cJ5juUT1{Ju$SRI{87A17hV)oz}bq~7AP`_*! zJ^Y6j4kg0@xKlV}w{0|BwEiq$-uINikVwE1+$2w+?}M*;bRrE_g1i;5BnU7fO(iYC z&8&iZR{qew;|+Y5H7tpmS(03ID^<{z>R3vgK@pR=XqB!o$>WN?5~T~l8(WN_cbqm~ zd8>RjHS0Eqoh9Z&Xd1!g=+0RBWrFp?=yvLhDL|JUVyhu1^0YQGWW0U9hVz4pA)LXO6i8& zXy?;Za6B7cpwlbzIWlz=01j);^Q(81oj|AuTHwIV%5)cj6&C{ z)xu;m0=)Pv^kUzkYR*>Ww}D@<`3a~&lhM9|Rha0NYL@lBDaRtRrG1g4QNJ5wu_oDw z6S-laC)lBy?U~s8F8-CI!bxz&XpG%RsQp>w+657bfCF#XjMW)sYf_G~wF9YNfg>ID zbs>+iDT(|Uq z5Tb_PX2lQOlU@amFf}m8rtk-F=?v0;e5Pi8C-mut;az`dSg@BI@N%L);)1Vp%;fQl z*OH9ain!T+V)UyFm=^Rluqq-#5;-t9(c>Er={#)Sb0yDXP>E@vSK}p_D@y%Iqh4!X zio4m+lpqNXR!dv!6U+RMq$yFS|n(Nw3tM1mTLJ*BVNgTkB#|P-M7EhItIfL)s zwHG*aDVYNNVL)+Zb=Ljs(vi*0W^;7vj-AT(yN0C;GG(n%_YQ;3=JB1|ic%#!{}cDD z_j?j2u$U9Mrhl2N*uojLS!9z@jM8E|DT$$79jPWoyj0X2&~?3?F%UmA+Z;peAPxhr|N6HfWzOl)mlSzZIgi7clqhA3QEdro_qHzOw=cW?lGE_%s}Mq?AK{F|LY41C%9 zR-iV@X|M*sUT6?n0g}V2qUAF~6&{R{ZX3d<>~L~6XZb2_(4St0vmQwFjQqMf`g>J! zWbYtl8a&god#KTk+#reYxio63E_Xz+v&uv*-r~{Md;{kBd5NCX&bp~udvr4^35O1( z^7C4(tN12DL`ju<&91o3mppur!KGQlV*A<>7$JD!mzW8W#XzGP*A*Z@{HB_!$byCN zl}{N8btY^J3as6%CayJ{4IJFjS^Yr#7AE~?Uh5Qot3y{i@R@k)$v4%`F8$nI`V4x3ccsKLH3ID3^4oW4Wd~>{7ap-AAVgGVu}~=G3o_Np%Zr$}>UE zdXKe@gS#(Yn~BHpeVurgCkLXb_21#T-vUR^k+6jqlU zEYj{u63g)@NZ6tlELg^nl;XEFyuU0hT|ByP&f;me(P>%@|E{ffyi!^LBxo-{6JD0X z^0p+?EbXJfL9u5lA^$4*cw_!|*^gDvTly*kMV%kma>?<^{QQ(hle*8<6yLSCg&WVOA zwF>36OLo2`KBvT-P#aq>Uw_#!(yfU`!0|s`s6Vt0$Nu2By)fG*AmqSB)d_YeZjuY< z-OJSa>RjQ^?_>ZTccy}9QyF%b7Iw-CgFg#{Yj&$O9oyS1kd6+}g_i_2M-Qb(AHJyB zbASm`vPJzha-gI*`dIj#Pu&;Rw!L2~MK>QW_F{+{L1Ra^N&o8iwBdcVIOT>2RiQl? z`#dgGf>;r8ft4?1eURU;BjcSDYkz4N2RdzJg6xct)845ggCvl!2*TMqFhPS^X^M;} z)3ZRN=?N{KC!UBzASRmxpx(K$VamPb`6fXb&FqGmpCm6$ju_ega?Y(ku=FZYw+M=&j+6P8?#q1zL!M%c?{5L0m zwQe8?u%rFiEKS}JcU}#vnVI5TM`^h1OBE#!DJX0(n37EcQ}L#mG+Q^DZ|mEJr~9Xt zhfrT?_sflTkn!5^?Mie*CPN+fUyUE2>}kPX}7-*(n+~k)08~+tTbvO)hG~VDdw0b({F}TOIpy$Y5~k zo66Hg4-(9Y?QyfUdi{zcgk%~kN1w{tGy)tYxZ^WMDHTyDKlOV2%w?ItMTq_ zpLJak31InUQ0;21B3rF|OP<+Q%#%Vu*VlbbIZ%A~uVdi*1o32nWm8&=l6MVf_Ak6% z;>x~%e^?J{;rXVR8afM>V`I1IcF#Q2~f^U z5&$9UELLEtZ_#h7WrnCs6khXmZ0dO<8gRkp_s(Z0{psY5$8CLr(ac zIAo(W9t4ghz~famoaR+hk-|lYJE=C?+^-NmqmkS)5jJ0|2SXb#4=XYaMQ4(&6Hjba zHhzMl-#t?>4j9AI>2wQhe#r#M(`%?lzY%b3t}riI;u@|N(w4}8w0?&PA7w1$mqN;> zN;RCCt~X&W#JhejvvLM-S?h}IicQ5cazkxDOf{niJ&}%kqEGeLi<|RdCb!Yu!d(+* z_Nf6CVs&1qX^TkZZ6|Y7q-AzTu?FeNYCWS|BRscRb}QIVhd*`@`bNpQh{Q8p+Ksz@ zg#w&t;|rTTZzKBABt!&Pgg8a#{fl=9Qr29E35(s$nwTP4O|`m{sjBcA6)(O|Kk)Sq zURpKtc332J#p6iGpkJ3^H=b6eZ^f23SZb5$yMD?*nvJ)_mqf|#!(xG*=#l96c(+8! zxLylsy^_fQLFsXluavZV1i|IMPL0vquRqR({?b-Utp*!P4lt)(<;mV0gaj?}C^nSm zdP@2sJyGYe6CA2g#yc9g08zk^v3w4!d$IzD zow3~t3AaBeg|LNI$pxLd1uE(HLXwljYMk# z-Xe>x9ol`G0kEh@9^7_3Ps~G5Z~{mJrK6zO@(o1-d;5hf?s)lrx=MBzWo6J5BfhLG ziHPM@sgPeQ#kk*sOjfu2TXY{ety+Q%8+u-Ung3kBxA*%eyX0D5t3N|0vK}_~PkKE0 z(y+#X;QW^WMf)d$m9y`ba&M1)D?T>7x8SKO+#t~ z^2^6miMOYSc9Sg6LPWD?&{TmxYsDNu>D9C8*tg`OU-vd3yI_uy?kIGCwahO~B zPhiog+va&YLzV!!&whlW&Zp)phpJqj8s%L{a-G_X6*#)>DPr|+F+5cZpCcsnn{`5b zsb3jUa8uPSy`@Nw$(;1KbiqT8UF;lD?xyC9?u}OTGmGCx=K9dLPl+p?v!2v^ch{Z) zd6vPe_w^>Sf?^+sv<{VKiwD&vcl3uL(debx(klL5_S?TtLL%$GUlP7kJB`HQ9o+s7 zV(ScY7LH2?(nfX>Bj&)*IPlP*9mlF)DlWPH^Ig{* zjvrS+m{cgbBxFsIJ_cZ0_FVyAFmy_u?UDJ8G5@WUY3Q!oXVa~1OIbPU=N7k~T zRlJ+gA?j(f491WS+#(N2u3C1}D~MQ78l=*X!21|a1HWB1wd|I6ouU@0u?d6fx!tjBes<+A)FFbDAD=DYi%FG-j#zq9ThmFfD-6*`k?CuH)MRJLTBzPG#o5<#S`3BoFpXvWIU9g=CI2Ff8)<@A z^)}2wc*eqp1ZzJD9u0&J>k|Ii3E>2f=F$k?z?{hcw7?eHe|$G-G{mn-?v*9-?uavr z{n*d+;`?gg*gOA0_$4^9;+-x#=rO&rL)b=H4xDu<8*{4G@S$oXY@4?->%z=Naz_rm zei!=#YAzIMfitJ7kK-?*ej>tsSLglN>;@Hr1n;sFe2C4ZF$qzZ`4eFM7FdFDU0g^m z_bq=+W`Vlt!{dR&c6v|708nPkdQ~n3humw?-qrq(qi8+_)R;B>hN-u5eII5AU$^O^ z=rWl*9x6V4SYp1ik#S~BA71HsaJx+Vfaw(EO8%F3o9A*R!f^-Hj6XUlW4i%?I|`|l zK{oYU_UqIT$XP(-eW3?*@AOJ?Fkt^s!KIG*aLHR-Oxa8i7-Axvo)#ANNS4x*?xjQz zS+>Dx`$rl?gK1DYN0Lqxr880Mmj5^doQq?`8M4&kmZJPsk-|8JB7Zg0}yYQr=OgV%;ffsH;RASL02 zhf^brlMB}tx~fnHM7=?0Mg~aAOs-0Q^V!s11#}7dVC~ngqBQW2_tbhNv~~>~X0|x- zxBgRN_zfaanwYT>*Q6-W(u`MR2aDM0C(=;O=s0P13a=04oAK_H-nvA5J^hw(SAP3* ze6`H>s@#`kx@vr?_4?Itz`OF#wjP>TY?BQl?ED^@i!(;WlwBckpZs_JM(4@=TH>=> zsF+)#-IMTR*`;FJM@^Z6vydCe0P63{#fLA@GvTe`d)6JYbR4x#{+G)}3aq#8mm_hJ zYvQtAtuMstiKH$945;BH=+rcMKf`Hh6UzNlOv-$qvP(4x_tiLl9K-To9wKQGBwjVd zc?$XoOcI)Dd+t>Kk)qb?Sq~4Vw;!ljTG(6DN6tSi$WuN+pir32C;FpamP(hebnO@B ziTol7Y~-2VwHIW;xjng>D9Dv#^{J+nGy*Gr^U0s1J7nh^THtn9_08!qKXC*feOq@A zTWrzcCUICb>y3l>ky1Q0&2=^ z>s8c+Z{?(zF+pSK+QK49^L^d{4;!Q&Uy}3}DMBsUH)3|3C%9Vw9XqyI1ZVRB_||5- zKb(gXS!>P|h5Y;Z=1al1==LV-T^76uS&7yds*1ys^#0?=^d}3{Uge4zV)BL;JFjxi zbT%iIxY_Jm{oIsMJTgAQ3fb7ez(AXIdqnA0KL;!k zjsar?_FAd14%*V~vdicQlapx{ufzN53|bVO^iJ+3v|Y?mZf$VIW$x|6yt@zjRo3qb zsOJ3v3kb(;b#U)2aI<|QYw|^mZh~kAlD?LK3(TT$-aW)Pz%a+mxdf!$una&x z*e@c*ePw!ly&XXk7$di<%NtftQ#0XacNXZIc+1GO(3QkKP;qO-H}LTzFXx9|iM00& zT9?)D_^D$fx>b~mXjp9ae;u8kzs0)h2#haH!1*`;pbBnHGYU~AkmUms##^m-ZLhd` z!VDv2@+JyGbXJJN7-PrTD`%l(q{RH?pw|Bftw2)0y?}ncy(5a;o(2(>^klTpke^k1Dgo;0^=Q|J!Ap86Vw!I4$5d;-*3Srf#h;aa zU}%Wl=krzjxcJx6ztV^9FI4)kD2)DN4!;k<=+0n1Tlsc>n))y5gQY+6cgUDpCHB`Z2O+8q0JHP#{3gC?!|jW#wi(#FgF|40HcA z+P?Us`;EOX8`Exrt;d`FyT)o?$1u=CHIU0KZ^10~^~=spmnSQ^Sj%UQE}hQ~54NQq z5tFE50A`)m$lah}m6 zdww8(Wkn2ZI;?1o#_DcQC#^GTmnbOnQ9-R{Jba?M4jHOPBfl>LZwJ z?g2rR`mq^?>a*oHwcws54`6&(L&5gz3QX!kKx^WgGjZc*Cv2;s+w17Z(mx!f>Dx*u zNB^c|Y!cf1g889qT-)^)F}|rULmBn+!^!W=${A)>tG|i?m`32G+XeXHpZp#Yl2GnC ziliYM`;SiVG$RUM60<*wa|JQ>jNeWN#@|R|$s7q18|$A8{j)j?ulN1o(w76Z3(Heww{e;WN| z^|!HaZt0tA%UAhSO%y6a8o~0L$`ADSsC=%*Pw~fr@@~F)4x%+lGgLeW5K-~kX>|@j zl=|E9lYyJ|0E8rD8PoGmE)f6{QZy%KnumN>t%yUg$06(jk-flKv}P$UmsM?U9y^qd zJmwb$IH0beEYtlI5Jw|Y(Vd2>l*}# z+14RUHTNOS`{)gqak2Y2E_9zjKU+nb_n}_!R^z}p{j1&=!p3!d@7Cq}l=ii|4u|Pg zKf3+okIPU^{-_vz?J3$EMkD7(o!mg&qH!3jKdwUzVABRb%3|3}hdBWdL})|}Ip?*L zY~gi4Sr3TfhCj8{gmwpnNnt~L28b-Bx>~T|NS!>P8rhm3+P-aaz&kyBsrwXW*Pg_D z=O%08!qkDyo~!n`sg^#F!t87VW?vB^N)X8e4KaqP<{liLdMmE<&thTyDJ=ERVs*HP zLAqvb!i+M2rZCFj`XJ9W$S$^EV^7EEai$UZl@MAMy+>h0{h5KOHf(hKxHytN(5O0r_Ca$L`g}?Q_K*W@|XJ`~^%jc5*YC49H(oe;`I;O=`IG zu6%Vd2Ah9K->N?ZMVM(G#B}ojdg%(U4i~V{J%x+iQ&<}=8s%;M_)!}U_YXVQ7{SrY zcSZ~wBg@o>4ov#_cA(G6w>0kQ)4k=h`jn&;8m`|`$n?Mn%R^cT0};L77lU@snJ2ZRhTBWK35%b&wUV-My#HyQj5fj!V;w52i^_4GbFUt<+NE8A~j z#XZ1JiU@+nv1{xE<~uiGVf`7ru=XgHdZ*DHURDFTf-qeEr=C^N`DUHvlr(3!mmkzl zFyD2#-TpH9yZP0!JHq9+B~X*t;WY}@xPe?V|Bj82;}0A-9G z7oK0PT#Xx!&Fr=?y*HuNZPOsQA+x{)1XY@>fxuqU+{~A6 zGcV^1rnNrh`>`^(fb(mQ z;_T`}=x1F`85sCh?^gyckk0{nQ+%KrXQAF_CqFb&ea~JmFafXM8@~?Fa`MYII57rK zH%5PWG8TCx6KX&Q|J*IWEgJy28KOJyxs5tw30*q3D$-#d185$(b@ukTy<=Z%(z!Ox z)Jj;r_OLaA!Lf{vzAV z->9#H!Jzv5z`|f%2gaXA&y1Bz+4-f@*|PxF0IZ<+rJx>w0e}=he*E}xJoeaQ*i72g zIRGIlBw6h#k4-iA6?csPVDDq95~%iZxujRmJFDmOQ3f!|5~@^yzje?G`MR~)yFZ0+QA4Wcd)t`}X_P70fekh-<{$|xC7oT!(-?&#UMD=TTjXX80J;Q5}UD9cvsfWexvv^|hFVP)dt(BzqZg6!D_$nK$ z;`O)v9vkIlguDxZ2)jEcaO>bfvv~Tb5$uMtc-WtJS~8ww=x~ zKCOITAJ(>?L7{k8e7$_w*X+n$+|=WbP3i4Ev-Du(k+5C`_M%P!0-KBh2qHjOtGfUp zV(4XCHO|I3HIv@D<>t92#Qu3sTT5_)e0o_AP5gqpjRO6Ui))~4BcOtOp-FFx$D`#b zzn@?G5(fD?j!u6?ASR$C463W>fUqQNx35lut?)x2XM^GZ7|mz`N2k9MJ6ebF_|jiu zsrUQ{t%PGJ6+PQIw(0lt>-w|KLD&5qme8Bznz8W_XpQwz*LoF%uE`fH;ucN94*88`>wgL6zR|=vyk-N)lXFluo)i7|nQAPkbXbOV+-j)Gi>#LjT1zT<4XU2Igc?<%# ze*fAP!l;q$rtt>t%Q@$T?$hX{D>y#$E=;!$82e6j`t{rE$?J$?w%09C$Ip^?1V5uV zZ1!B$M-bT2ya9L2-OrCK{#RUDe-bGVD*nbl!S=fOCHG4I!Jr$8bdl~ zy-iH1aaqOR#9S37WyN1((jZ1PF}QJdoDKTw?74;6OAw_nDVt~Y?eEu~I3B)&>jo@WP+r?8+A?=Tz%ULu~JzfR(&POh7l!)sMp92F7RApRT+{|2h1$ z_MQ5wn1R_|=mAsax1(vgWyjai{;@ZZpa`mO$ic|E{}9Zt_*?n@_|PNQ&ae8vCf^;e z8|HiZfx^tz_%rjlDqoE`F9v0J-Brx1#Qeh0u4>PgV*s1n2Gn1n4jrJJUNugQN~Td` z)k=$!9eTFDTkp85Eu%lxwqBUO4FN137VZnn6OyAmFk}kg=wdWKQ=awo3Lahf3>Lc2 z;PCX@(TR6JR2wy6%g$a_h<_;CmamqDM@u7VTnFAqGzBC$HuEk-)WB2Ae}jy(s$`{& zkvj&uk3$Z#?S};Rf;QULbKM%3JG+f?sMyzro9}$F=>xJGpK1=)L4Yl#&7K4#=TE2; zGhY8%0<)_*RKMg3H9P|{TjR|R!jMDgp6*va08>^6-xxXu2KHHRZ9T(-#55m;c=Z#y z;_sBN)Z)w#W(HG+^W7(~I$Xs5@jI}4>=q=lW!C7bM_*Y6D9~h6088EwKLqse6|Zi1 zCO#;Ht}hY^ZkqlI4DueHU;P3y9>AWsZQ&s=!dFXm5TTEO2T>#Jev2Cj@3>mTlfwR^|X*jJdF(muNQFWMQ zcG=y{^)Rh1Pld6019=>N>K}WpQH%ooU3oafUyOaBd*)}Q7ZC*OkU=KhMG zJ?Fz1-sl8JpDxA>#W-w!?B)l$lZ^_D;x+my5<%kV)Z4kAt>T65qtF_0E8t-H;?lJW zb`yAnJnr}iD8LyIo{4Z8SO`;Jq3&^aJXF`YF-!rl^Fj#LDo`;wbK;Y8y!w4yyf@C9-}WzFYuodgTI??IVqqik5ox+Gc_c`m#VR|Kdh*mfPx~3VQ|U;XkXO z=uvi`D^C#*&|O`+0OdNS^+Sr@IQ!=4C(ZkOse6hpuRn*`_F){HxSJ=Eouqyej1hX4 zqQv6m*dw*Zl5I&C@W4gd+DcmOiU3J0uplrOXL`El>Yl!Py5C*z z)twox4;hDhL}b19db($N1~0nnRYm^7^aQBGJ6werBPVm z`$gZ@z<^N*{u?*N1i!-^bi)}4!r(~uKX9LK2M(cWUsw%eJtSxGlE2Q7M|W)=kxHg5 zcGe)Y37}Q(mKsMY9;t1;xI~-3STpDQFft|p3lo4-lSEsaqBR`qSMwBi2bZulx{9Y( zUc}?eU&EQj=TTA@?IuC~0;};Fw;^K^#K{tqjG!wZ+Z4|aT@y#Y(mjK(o%(0kp4_sg zKs<6>cXO)f3fZYHXYBkaL8gR!56jip$6Mxi+(ty>K`sUP^fs8exK#P?+x`cRunXYW zqp$dR2rmH?n#(8FQRUTnOc!Nw+XY?Qc<9fqPgG)daB!yhOpfVSO3_*$hSs@74%2#w z>w9nD!p6VG8~1*Uj|LYpGn1CeXc48jl<;pZc~Xyg={PDkW|*7!aple~ehJU5{*I!H zm0zC?v;RoMe=CP>7z+~j0!7p)@W<-y9IpDm(!vuBjJqr{d=lXSz;5ehf}8x=A<{Op zbU5sjfexhscxFVM!hn6I9Luzk(J9BB5PKdr(Rfeub1B6f6dj^Dl0E~_^>s4*n;LOG z?k{HAC$oudIN3h78AQ!v#TNM^nPZ&T;NY6a)gH#x9_|g_N3UGrXIH<8$Ch5eQs)#F zJ8RK7g2;wX*{$1~9=l-Cx@3lj>@`2){5@$4PAKq|lfRGM$!*>oUsJdwF;V>ur~Hk5 z036}6l8MP57m#rxh}LG~vDYI>FIRUH;dR93bp9k-Y*bwUz>k0Y<3mY`!^+w^~to4yl@IOw=~kcaEa<4Y?kIq&ZhT9L}cVKGf(RQUD^F5uJ65$wce9_df^#5 zv-m}<^`1blTw@9hQuPo0C42_1zS4%aJ`Hyvw&4-+LsU24{}C>IAWNN-{LJb%Xm@%S z6^}#zh!H2#v*54Q2r~EQH%=ef-sJfe7I*YsX?Nq9Wq+hLJv>9f5Ka@>zXUW6z5>+f z-P9~D1qdb@4+uULWz)JLM1DMiqQ^x%6dwRHCkt63nOy5zGjR!G8*&~2#7dgaKc`Bk z_Fu@a9dY?{2FsKIVF9WP@@d_-^MQDRjMH{reHwtnx9#wm8Dn#F4O`>uxVir(dgU^{ zdG^1?$%QY>&p*3P9HvKG7MO$99gd^n0-?Y&E5D7ad#|ED{Rno%hqM_VK(Ycxzy0J# z+Lv~cw^Wfme>AxApW_RIZ~plG~Z*TX0P6XHwkML#_C6IFt{-)PM>03IpKL zVETSiHPH}nS^26la%_;b_l$E|Crj&$^Z7?1Rga{N7w08xL1BC+^$|7IV}uJ_QN+ztMLB>(oHb4#kHN>qbX48epSnIW6>pT!r-ReX8v zyV1nf`Nci}h#L8L;y;k}v3Q1_lkpelZ$B;LlLa30L2?d8(nl>7=62k>+&q;vofP*O z4y6GYW@kD&lS(|dBQ=?1$4eQm)4R@;Lz*Se!*~;I3^tt~3`zMq?z%$9Rek(O5A?pv zl%^B4S6xW!6BVuU3yb(c{E-PXGjjY_Rc4nz=hH|16Sp^aC2LcM>gxV)!98!H_PhAg z%iqA;#lOY09))Vh{llEiPXK}o;frHD;g6raEqG$#N8{7R!^*DHn>;_-_$3oCvzd)~ z;OC&6k0JGu1j3;-0Is=l9x>P-J7?5@JKHXIU&Yyd03awlqz<@EOr9L1(yIf`E`8># zwln!i*2k7bQ9RiCQGTIz&UX-yRDqsk)%B0J6g|Yt~ za*p!XwG`rBt8}{DxV@q{)js)Gf2>#sqW0;$b$dPYSeC33Wq`OpfXYm8t^aFW?f)IN zCpR%OBd~K*2NtML!jHJyMJbAxo<7vX;ROnOVdXr^qN~*XtV!oE$G7{!_4tcZxnbkS zjTl*;@63K%Pji;lwq83g!gy3XvzRpvxSsJ0b`FY9w%ZRA4(9|ku5Mxocu%TKh(p|; z?-DlO`HW;Ny56GUaMJ#?+@?PB^Ouy2a8Sv&Ja$60@SPI^H!P$RIa8k&q2|8bLd2?m zLcl?s9dD5aa(+rDCNpDmd>yw37qHYhg;TvR;EClI@bt=gbZB9IyIY1|Te?>MGXM}) zI%l!keGEI3+bUhYmye2bYdzvoIQRbinBShGA};p&UH|F+jC0e%LgI$O(;@be3V{0- z0*CVf7z0x`V$7Lb@4mKi?WNy;GPK(@f%OocYvU%#IkLG~<(|n>M7&0!@>2z*(ZKB} zKha4-SZC^^tE_Eq4u4x}pZXXgmdy2ei1vg^vTD}BJ`tHc+bafx*%k)VE!-P@z&G~Z zz?V<_BRsbF6?BSTd;cW-JqQo=jq!zY4J)0q*qPjxR&dO0)p==oQRk`(MBS9+&rt>A z-eI@0YyQi#`#%44d~isT?GKiqPtR;-JY{ABpthyCiO-V$u;Fk5pa$w@8_?Stqy5%)NPBia4E|t#T4^K=9u{ZaE$2uS$VlrG_W7Iq zye$f{^%pf_MCS;?zOjqT_5r#*DSyC_I)!Twh5X2Foa^*rjgQ*zaNC5IeRGQL6@b&+ znij*aL_9Mi+8AHK=J*<(T>d(L?bLrvYrQ8>P^aNX>$w0N0?(Z*X!@sDF5yJ)DZV{? zn;5m{pX3)u{MG#A2EXPC((_{(5!9+zd~M%;U0apAj&##iV+HJ-%IY(pQ$ zvoSD}KKexDCV%aITXH_?Ie^3Y01U&_k=4SO*0mAuz2@YXl7uXiBq^Xpx5zaSXv#WE z^79Lsko~%NonM^Of%VDOF?@82__wY>UR0-7LvupgLyjy~3wK4*a+;Z{K*&)V5^*xa z748f!;#u`)95DSYvu5{+@UQNIb5&>m0Za%Cxkta)R3t zuadMTP-s*S!d(AJ2E=1p4k@m|(*Z`dG&k|%ZNVdj!|eiC0Zuqvq67)7jJIU#@IrRKG;f!&x?oh&#J(ucsDY-Re) zSbX_w(W0MBNm(C2q!-!B5~5kt)Fz&JYVxZgCdOlXd!rOlHoAVM4S*Yz8C>wc#m3I^ z(=|c)AOeZ2|Fj3Dy(5w>x??dZ1kqkVF>ubS>-TRSQ2dy9SMuuB^6lgoWs zw-2nJxfjx>`3YN0?eqGy@xFGjHA(CRr~CHV_J;Nm4mj6f_k781phWHWd%_70fy?sJ zeRj>y2uH}@Is`KLgDNwo_ji7V-N{FRo?%%l`3Z$54s2?6#{n4tSJQfO=fk}lQbIY&mA6IHUw~T>{n8`yiK(L;yj+g* zQ)$^>Q#m>rn-P|YO6%GZL~_19QH@0d!y3doU$ye+WmC0H)t*_T7xnWE({%{48s`DJyTaa z+xlp@ZfYLsai<4B%sP*>+V~+=+=ZSoVnWGI%;lI&ESBtzr(i0w()pXIPo~C^;Si;W zO0`5e8`lde4tXZx$^MbbbUN5X_lQs0Zp}~sn^~0K{4-qR&fpTZ#@BT&dM4`fql;<( zX?(Ff5v(Sbsl$W|<1atKVqDiH@K@XTA{c;;7zmPAvJ*Hn3+23EU9Ork?8c_-U)}D( zU2LM6_}&DtpQu+49}fLQAY&MH?G1DTz*LpltY>DMdc~6He9eYA``9dJRFd9BHQU0> z;!Jv02AfZxy^z)?jVC$i`YSY3AJ)u>b4#nE>+iFZ%vCtUjS)%jCmd(m7w3@Sk21u4 z{&D-7zlJNm(*GqMU;1ija+H6zf1>Q$@Wo;!>eTl3I2Hhz^z56L@?U!J)&2Rf$6y7n zdqaH1wMlVJFVHMKt1HZGhMVX!`$=CR_yD{H@KAgJ0AT7S|Hrre=FKHM`P= zpLZizF5JsiF3FSd!ry9B)r+@#R;No`zJpWqJHcP?!tg!;$?u#GfQdbQoWHkd1-?DGMZ42G!RL_m zaUhhBfYThD0T^W?hxRi-R(81Fv;1v1KpFnF^1pyMyWjj)^wD^e{B7?aj&SjALd5jV z`v5t@-S`86W<%`PQ;dmdW@t&^c=T_1rF5@o{uc+8jmXW*W^e77?VEwTwwyO`X^@rjHqlyu4`t~I06g+)8Crx?W~V)F>@`tTZNoTqHQ$yEgKF!@kPpbh-fMngRrGO$#wR2 zemeR2fO2#7jAk5Sfx{|yEr%@MIaqUrz1e2W8Cj_)KV1(_u1bj@B||zfbUEp`iu*&0eSsh1AWv@DP!nI2 zsB0lv5z|A;b3v!mjd#$m8v!sAAL#?QFGt{!o(f!723!&gyQ7+n>&wRQlnPq(Tyho9 z*!b)(vP$Hx5KAh(PI8>TOm9;%MP37Lr?-<|&0~)06L|o%1)Z_+$tEeNL{XF|s9ROW@b0}D*#pO z12}3pk^nF@quVRFw@{kd)Ktt|?QKmyFf~s)B`w*;N+ZOcg%+J<BA3>VGrFXVum6bbuvD$r%3+mEhxr}bP zjBc^S3*{P>)Wve=G&;os_sV5zUhCGBCv0z9K2)5DXsL4w%bnBMt#30(-izSQrUweu zUJCK%@#N9j|Cm2^OvCkA8%?|g`rX#I>k_A@zNGfvYoL=7_1&7~m}cxw6U`0~l$M?odJ#gZlAXn2b8niSj8c?>LePGY5dHryJk zMpQ`X>ZexBh~0j2L(G5KT#?qv!#I7&KfG6AS`RTZV=R=bIqroyn^cEe@rVs7kzXcB zAp*?N+h0ap%njRUhUPPpI47hjeLVhh^bLoFItmbBHo|zek3DGh-)#jx{Cfdhs4Ihd zX4ursw(x4WH`^ceXBV6L#@&k~?rX~F_2!$iF{OsF9Ne)$2v(S~_L8(weWIFpO6eTn zV8v@&&tPW8=oU+U^0(%%o0rr$svZEKTl8>x;Te=gSJ9;X&IhgniC~PQtn)~0Vx<6w znv~Q-LEWg;;iqQ*L3oJNW~zGAIr!4C5p-?eKPJV!gAclm!X!=lBV6GqwaC{OE=Q|K zuR34jnl;JzdOk z1PEx80Sgku8LIr!xaFLR>yo1E68boQUE`1+lJvu>&y#v^K>l(02|MEE`uG#eU*{ez zXZUqEdUs(y9&tS82WwE`1(i%FTHCMdi;%;-`h&4ITh}E@dfx#1*KqSLJ^v6(7e^D1 zyCi8})To%h8GeP*9iHYd{I$0D#61 zuzaG6vQxOT$o0Fw-h4CNhmX(7hka{uyXoL)U!DEgeYx-IHip3=f4^@m=oy^sJEO5V z{k|)S`mv~AsDG1|>^?L39HH71pKF-aLziuv=NGb<{3KfLoTAf< z&&gfsz(2RI%&c)jls>8tVw{0+l0dZ&ZeKjfufaOhqfXI-JJ%ojOK{ln&wmA+aySE{ zx=*eTU4MoCcH>E$ODI6%Ief1336SH1RQo7tUmLC&2-`r{iiSSsA4U8lF@8DXZ#=#k zT*D04>8`c?r_nipn!+;y5B)L_8ICLf>YBMI$PoE^l4t$R@kirfby@4Sk&>g-N~y0z zDyCAf*sF)BO&`)HSDYp$RIW62OMN)4Ppk@B$*$s~Y8R7gPt_(@V{JM_RpgWd?u$_1 zw@&^?D5$h5XHZ#lEn}~arj^nB&tKFFidLEJGU~|)1y;Ien|Bp*E`QO$1m{-VpvJh` zQ#gxsnpZN${yBVEb>Aru3H>}gG6XoTkr?mxkM0@$&B=U_^nt(i4AcH~x(#3gU@AUx z8}O4c0RZaFkW)p*bo(0#`p>(i(~oMfkd`5c4p zCUR>LIbx#91QRpB{%ivr3{NRl;tMSqBW;nmu-bj1IdUtUaC-2__Cx|kRap*QU51|k zaD!A&Fj!4Gl+P%OE>88HidiDYK&Xf2aLOJ@Kdg38Ss#F^s~Z>5{rL=diixI@A+7~kBqwA32i&zz z(MK6*{xlalw16`UUryU6OZWYm!BzPIA67f4d8RV~=A7MEBPfc+{g>^{m>OOkNa`2& zr|ZcWkH%Zn<#Ig60~rAN*XTMkPtj-q6Z_jc0G%u0e$;>9J~V(o`;#AVT`^8QzF^SU zAQc--`kUhqrjz=vw~5>ikFcp2kVdYyLBobjuw0S%2?QHSq=&E}r41I@RKh-QY)V(x zs6NUDZ|t{Fp0q&)zBRsvif7KE&HF^=*x(%F9Hjv8#L^4&?CQ6OsI-T_ecjv{?ZoC! z_%Cg6$uY#`*%BffnI%r3FRXln7CUR&N82$C*R;KOR4gxCsm`Ef!J zq^K?}&X9$W*7>THE?yR+^uAsKAI`Qhm~Q%<)Y2;}CQ`J8!~hXmz?as(gA=_cnv03e z!x34;5#21EdBOv|c7?St;9XkA^Q->=D&wR-IRXyGD>FrZdKa~sh5k2^uePr8-Fhsp zyL8@HgXlV-bJM(Hj^d)8d;qTEvb~{nl}i7DZ_8){8}$VHNNs<){r9mw6B&8h;q-2J#xd5DQCaL(X^Ro?? zf(+QdLHDYW7XZ=;SRn|3dDHE{P#&Bl4Hw>g8{hh!m%s)z8C9jtr@Oe^slM>Si9e#E z@dL#5YU}X)Ajx;UcG9;=(3b0*yWS#nIITr2)ivJynwb%vS^X9WMW$I9A;br!$fjBC zokfopu{pkunk(H7+s^0L{sERdr!w@JjJdg~=cC~}=ubY1+8t-W01?hEei`35^Us^7 z_GBkqDqOO72HTum!w0)RM{Q;e%l&NO!##obK)eN2QaK%;oW-MfGR~s-9K3-U)rbb0 z7mEwyExHY02f!|XZ2)@!hJe2@h=2U!AK$kYa9=s zH@m9$)wNAYZeP^dycBlAWqP?U47U%=%3_T6krC3{YwQT6&6)KHcsmb%&sW;`!H!fd zVqYHoKJ~cTr|t0#Z)3;%)}gTuCxJ)z=d<6o7B*#bp+HH`u6-NNt$m9MDzpzJ=C|NS z;EwPS2pe71*i}I8Q``J|#WKBc@*iWZ^O&a({TcWz^KZo?9{^xzb9{}a^~l@!l;OW> zZMOXoJ^*BWDgn3rj{3{>ts8%caO+)BJ|*c>@#7Kl8H=RpGd_aJF8ZSl+ORgi^8rk) z{a+K20r0Wxf8gT@04_?HPL~+g4491S;pVO3-vZgi&wl0B)Wje52eN`3J~Jb1j;^42cxwwERC%q-b5i$sh3nE1zJBTt@YM2)&1H^x*uL7Q zt1kNwOA4u~dLIMf>!yzR#xZ(H#rGtJQ$n88PMXSPp$0Au@^*pF2I?|c8>h5Hr&fAYV7pR1YS zq9g-C1#73g@sqoMTg}Y2o7S-HZ+V7!x960L_>nl3f9Pa2W9~syh-^9-Nww(;R|CEo0 zH7651;nS%j_5;v zV${q0!yr-gs)}BI<>=8u&)lEh!_MRuR=Q`>ef_p`44lWI3J>MU%65uHymb1%z-s3# z-r4zUo|#cnUbg-b?l>PgvHaIk=M>JL`a^zp_1n#>!D9Y$JhXNH<@gmHT7W^#m-l`N zU9FV=?G22(Jbx-ej;}0(TW~erL`iad%K~zERI6k>jJIiPc$d}zjG0+#08_U209d*s zKLIEX_pJc{&2B(FV@C6y@(RFgJgBy>Ug*Eh1``00%?)Kk0W2**YvVQcR?en(akbA6 zVIww9g2YBsHgvLYo9=@Lo0oy+n02s;oe#s~k-Yb{s%O`#h-+BCx=|uNQo1R7aB+ljse#iMYW~#Q43o7aHrLWRIdg6b?^J~9L zonk@w5aiZ9Ae|U(xEc?^MMDf=YjT6u$CqvYaZ7ux&5XFe8NP2_e}u9`c^TZ<{I35q zxEybBf5zh{?azolnhzdlVEg^z3K31v?EAaV1dR`%mOcP{%<%CvfElBn8FS*Xg?i`i zxB@U`gYm}A;jj0%rys7K>Ao1+71z~CJaL8HFc1F!bh3*dD!PfX19$)^dDvwxIz={#sT8f*i$|KoChPq$9pbnl|IfqjV z&*JLdufuZ*RCe*Yo(s*@Ub%#my)WP^Cw>o4Exm|>I?ePE`>;%;sC53W{~|mr|6VQt zK+RL!7`%buYzL~<8UFe_rJ3mYA;%CRde*p1+>mM&(oy+H+)4C(L)LvUudY$xUwEsd~`q8VCGx|CgxE6!StN7_-s1ynId$ zTrAh{;;BEtTJK4)!Xln*p)xXW>Ek&iDrI4L{(gR?hhf$jZm{ zi(4G(!!7i>zMdcUin~8Q!F^tuA01l6`BVQH8yNJ8<>u}_*<}gGGUd18x_v%JiyN-+ zLH`#R%y!rdWZzy+@=JX?au z2=yO$00IE8hk`o9onGPISTY4Le)sRT{vT$pfJRP6vJ!gv)9s1GthmmK-e2Q@NuxuS zB5@su$dwV-i;>n{r%%PPi*HYE;LhLzng=MghlCG&0N$EB;t!`+AzRFd+7C^ijpeW8 z=+jYZ`70K`z1cc0?ff*MQ9Z$-XLad*<&An(mk2-p^7OfFJx!nDYK-W;f~4h#I9koS zm&?m$N&}?ozq9=x=;{wVI03)_OxYnN0PcP`czbVi`j+NhJYn>J^09>B`5^5?G2TVa zZiL6Us7Hh+1IZ1X2=_VZZ8J~FXpYyu-~T!GX6tG&r$19$C!XAj2lME&xB#N^5#yP% zv&p!HJ}p$kVccU{kMQ2^-(qWgBildWrvyc-+5b^O!1sd0!&M!ZPEDW2PGpYpSOl?v z6R`k!Pe!;|Nj9sAPxq+5d%3)dX5u@?U+nyI+kX!%+}FJTak%i-MSSy{=V@`ZL(@@B zY`6qaR8xaaw^)4qi%Z`oLKliT^L`;PU19MxIZD$M8jq)l7(m$)66ZlWkq|mDTQ7@O z^#sFe2ahj(1ts;;u^snmg14t>pD=SKxBYqa$v+Q2C~tT^N$i9E&++cgU&HWhZr`O` zRo$M)BZ-(Db92VoL4BL2Z_hiWhI8?ljn}$w4sI78qB-}!17H))`G0rz-|qlCDD@wB zKpFthJSwcN*etGgYHFTYHZ?U5?_M3gI@p=rR5rcz34}dCo_L&`scvh4c3jd! z;YjFP@t7V5;N-e3TnXnGG(Q|hM6@}%hIe*3*qvj)Grw?YD7xZUKO)Q0=_e`Ec5&4TOFFw!Qz4eey334@>|6KwTL&2Bz#%jb`6+%m(}K z{BrwWPlokJk}2Nll1zYfV(WpSZEYWqaF)33cj*L@Ie8N@!Cg6`zAEqexM~+OCe;8J zxBeaOjXqFLK|P(L8Ob?Zu_#X;*~KZ2_(~S%dGs8&(p89s?H$96t;r3%vhgEqjj#K@ z4cY3c=dw&}QNl8C?h zI*ZF0JE|2IIdbM&f@diOX&!H&!?mU68Mddlu+lk$)y~;Cm$+ev!c&BEUx&ogi&^}D zeA`B9fBF$#+590k$JaHFoWRNCcN9NOu7>%s7Nt5;-)35#_40c#PVMon3*8I-56jB{ z`T#c2-1FZ@WB>u6#tntW)K#Jb>Z9IM?F2=X; zchPJ>`?vmT@n>M#A)*}sn?$rjM13OKgZ2SDu)Tj79;gWbSTmqz)9F!7_GJbDMthsn z>mR@)rsJMJ~dJ)3$BrF12s#NgN8tTs&MeHR`Q5yKi$v zdkCuk9)kMsR{jUR_bz-K(h`sgx-^)}8dJm=2ehhiyIB&i+iq?m($9QrJVc*WPaVaCq%4&K=M zSGc_Ui{?EBg8vC`RY7`X*Ld3R0eW!#bFiI%Q2v<_Y%kOkOg3Kc{r&Jxd6gMk0JdNS z0D>0a9Dp_O;B9{w9*O{X`@%)M^o{cYM9GU#{xL7%C`LPYEam>LD4DC+u(DvjedgUUmbWT%2 z9bw~>Pao`UQn>a3#CT|L8?J5q26q}kI49un$Oj;C1`X>zT_3zouW$V&Hbz&dM(uc{ zZ2W`z$F2CB!S%fXZT&X~hxXwdx&P4a;|cAH%WuZ#{rk>UQTmXRKJHOcezrXw6Jn>R@p?F-< zD5;-WE!2YORl*n-HkeFr(*^o;wU63`t^knM5(C-El#XaxE#Y`^RXdNH&_{_c{QVY>N7_boG{J%HWT zpWFM7&;38NiSNQg(EtFi1yFaPAX78gG*Xa=Xs}Zay1ioY%rgt;sGv^Nx#Z&wzbWEF zh%=y{u#v-c7d1U>W6Y5a=vO3{K;azWDLv-mtZ;S{K&7wDgmxykaC>+W41@ zXolNuu(^?FPZqc3@f=)Dya-qAX<&sjGmXt2*2kCe>egT2-R-}jz1h9yQRSljV)|=- z{Az`b001BWNklKdq5ppO z9e|w8FSq{oh)C}JKh)J9c!&Z30M4I34=RX@0#wiZ)=FV&F2?)y?%8J-Ua$_qz+V5A zSz+}OAX3DuOT1=B@fKT;BP^TKk?h1w3f3hitD3z!HACDTy^Hnnhp2gmk~-*>E70RV z=2=>Ed9e-8k1(Et0n7&d=|{LRd=nRU{tEBx{1pAkUHdfTv~O(l)V1WVz_Y!e6XkpP zW9;YWwYoMu)4SX0X7%qjJ4t49$Q*_v9!Z}N!pcWZ;}Sz2FlZz-e+Kf7vy z(xLU{oKm=!2SZ|rAR0DwAxuyApQ94_CA5+jaK~_mZS$e3TCE=2-kHpPbOG)U3I7&w zR<)cn)3j9I+hq{f-(s3)0OhD#XNl`A`V?g-g#)&~v21-c$@t0={HCs+0ug+>R zerQE44~eRl`ds=>3vS-(89t4ij!nBtj=B)1+^}3bqzCw>dxyp@8jS4oDSnj;Ua4vJps+z z7k;w${T6bbe?_Awf9Khgr?3Alyus-u=xMUwxQTqrLSsT^csiz&3Y%!;;Zu~5PjWH) z3O&U^b1m)mln+t=BuZLyi65(VoP2)lDdJ?R@?y2`~(t!2{SMXn~>Q59@vQ(hZ$ zNxp2puj`5%i3JP|(GQsRbXJasfP+VslwR>qRHn)-x)Ls7U|wD>k((v(zI+f9+OtOq z+zdo5$WkzWU@O_QaGtW!xLOE_8Km7gl#?f8V zoa)4CmgXr&)mD|i9_h!wnoOwbaFgpFKCC}UD3K3&Nt29y(AXGS1xb+l^TvX>wA^EL znpZ)Jc$XnapuYeBRAJqD`flO^3;b&Ctu#(}pF_nr;&{&rX)VxZ1xF`1mUa$KRk5c0 zya?mbILv(fUZ>>2_(18?^O^}$(j;L~*HAZE5=l|e0uv6u=p@akW^ifP4F7)0Qhvov z)Ab{LZp8%C^yPe>=B~^qIMV&0exdaZM{ry0LjfkOt5`GEwtu}7gY~@jMaLr-dy%Bj zAIk?i4A3@l@}GY3b-MJm{yg5(yp@pQQ#u=c4uGwt+EyVu|5STlPqH!H#Fev1E)oZQ zKV9J}9-E_3@%qWJ+!2FT=v%JTg&u`=2QXc%?9P+I;G8Nh#Eu`|XM_cyx~5FKYHyw(ZUr|r8ZEb6RGFaFk1_M7 zbdPH6MWeu;;(`KM#MO_OS$iYS@XmT~Jc6VSHS&e=W_AnVRaO)2|5{&hQV8GBvuyj+ z+T0kN6z&}v_vH{8RwsOrc8RPQ^7NeZ*m!5?xlKwIm_g*DFJYHBekyWlF->AY@CijR z-}5eMq)Uou8Ir|UuW_8QRof z`SA0{yL+X)ZSl}%c)zj#9WI_+Mz@Szh1TCX^{|@Vu_qmivh`Kp)vlbiwDV(UvV>3b zXO+`Eqkp8aY88D=N3~d`NcCykrf#iZT^k!ogZ+8OZS&gDFKMdV;`TUO8W33trq5iI z2oU=4u@vO3qZ8#nG3rYVjC{{^actkOfpP%D&!d@QxI!Tvn#JG3#2ph)2rTbfI<2!4cPuAJ) zb()YD&0sU^qhrQhJLbK)^EBM_W=k^Pv+_MUYv1v&$g
u^)z*HFA~CQKbErJ-HA zGh!VM;l$t|L*W`zY7`s7_i#YQCXDzrpdR47BWJYH7xej>ah$8Uaq#|VQy0!9D>E`V zMm?%vMd(!PcWg!K=U}KSY4s}vi8V3_f^HSC#4|7blHsEq{(!K4~&e z5Pxy3)=T8@6V*QCY2LubwFn!HF&ov5iUgwA@5-_ik!TOi=rMcQF6D32U4u1+=>73C zJZIt2ZXp(5RYmYiVa!nY>B4G0S1h7%4Gh@>owuUprj@@_C5o358W!mOlW6)C~$g>7MpVf^pc z6<{LiRMNxl1=Nk=vConnlyELbUc`}3s)ENUG4Zatl?hvsU2FJ_7}GtW$JUint{g_b zRh17ykGU^8vjR&V9tULE6H3pU4~L@xD|Texs_!CNpI^g!_&Qyza zkn`ob`MC#2P(k+4{s5r=flgID2e_5kp7`#pp7LAF5SAZBEEPV)Oz_ZFhj0ppGB=Vd-) zva;;bBKgIQwKaTIFJ!J|vDm+9olTM>Wz2fpN&O-x`56zdXJu@k&g%6GV=QkcYc0Wg zAi>01L@Nh>1d>Uir(@WS{(x;AyIfShVH#^X#wav^Xq8=CJPp_iOwa=OX0YbQZGXP0 zcSP(|VKNLWe!At2gxt(v6=VDae6n`KSMw%QuLH^PYL8J9@pYnRuTQbCeO(6Z&gH)Db^&P~{28oUcw$&40`$DCyhYRE?5oiZCadbLu3} z-KYE1H;6ok&YQkJ*=e1$m-u0ibzosjiGS^CfW@W}_z?*FB@Mlu_nUn2NCty*XL=FF zZ4^XIZEFzB>lxxYyMn*@KtTcNwzz!)jQ>eHL2!C`v7a=5!G@Ct=DTXgj>j9_P?Wpfw%i`Pzfyl5HY>XiQ2CcU_rilF?{FYo zC1RD-Yf09e_i_<|r!)^`!HEvpYC6% z?Na_PDh!KJ`q|Sp+ cYcJI?TUTA7r2(NE`xbUpC#-memFw8IX~$&ULNvv%U(}Jd z4UApCnXdw22Lf94jdUMZ0NHo09)M9NdppAw9TT!OJf>~l-#JR$Mi&^J=*0`eCg8S? zVU&=I+LNM-Xb8tLJvuf(s_FKt8*JL8%!atIJHaV1EmvrNs*tHHJ`7IzXVwS4 zzFw)bXr7&W#|I?)3$s`HI11XUmk|!_Whli)d5YVCr|;|qP_C;b;eka)3_~ylJpmIyG5oMS+$Bz*pMOz(#_ z=Hnd^{`k;19*Ix*BOg6T`(uOpgI;HaQ8HW4@amnevHM8g$QSWN6u20daChy==+0*Ilx_NRJ$}@qymy;(hEQd z(K&dGPuvCy_wxG2Yj+N-Dpc%ktfT~VPbHht=*#(B{P3p|@|X%(($C?spMv?; zEwfHhf6YxK8aSrhu!P1xQ*wZFhTiAzQ39MkSGjX;aI%l~bwQJObAHFX>U+MXEn&L# z-2Ty)HQ23Tk-8NRN zQT@U93&1Mosv@IgWXY$0e~YaR^Lm>&O(8zd=CVvKdrGWT}0&5R~oFF4OoYebVd*c|DV{!nCv3x;Pi4C=x;lck|C~ z3TJ)WkDf-R;XK^73u^h-I8Lh%qs4^4#{1yJt)=!0OX@xd=Md}6p{q3qgwu=h;Pf`$ zUMe9qi!QY}1?Sp)7rYEzgw<>z37(RlgJh+{BY$71$(JaS3bnE@5&5WX_~A)) zjpBJraWhsYLP4fzR@BQ1Ft|C_1`_TXdWQaBAdABrwEwo^R?|^>B!>+8cz)UDtA^*7 zuQ{5jJ-z&b#);y{C>Gy4ugq!ZH2S_{t8T$=gsc6>(BUgfeaD@jp059jko%aO0Az$u zJZm%)55ycopw-(gg=RXGT>r|Vx2Z@hx~E-$IpGShx36taE&HoCYGyYzSE!8}S(X#V^u z8g>J7pkhEUVg@Rh^Fy(!l>l-sP3}HJdE`=ny!_|2mWBCFWugOm_%LDlu1R#IBH=w! zHQ!7{@QKZ6Ne!bD`*?s~V+m>p{nX9x?T)63dfqPoSzJPBG+L0mNl$OSmn!9DlsA}fn^XvlRrea{ ztA*b}hWzj$4tuNFmp-;=2+r{C(cDLRNc?^wbwJm89CnI3*y4{mYJA!1LI+bDatcXn zJxVK+m&^4gd2G&*5oN8rj_s)rHX+LqRfioNXNv=>8Uf4XmV+D~AWO!!ACZ(bpG#Lv z%jRPdK_5^oR`SO@dvCeU7qe7df2%-7VE5M4EAIdH*e1DjO|HPwE83lANCSd@mG0Gb zvS|t3R4f)}D+V39p9_gEX)%Vge_|Bjr!@u9jt$`$!(+-XG1r_#Tk&IM)gQWem{TKK z*D-bPBPa0dHQPFt*5+1Wlaux)(jn(EX0xCuX&4k1djZaxwRllix0u#~Z&jD$cBz1O z9vaPe+@beqX>%H}@w+c-WC$d3%CFwGA`|&l^z}-$R+h12{mX3x$M`Sne7Ws_o`0uz zQytUrw)YUG?o=x|C+f3>$kdMoLP`YO#AB?DA!*{msE5`Br>h z2*+MHm ze$)wyiUk{o1qC9kWw!9x1TV$17E39m1!ycF!Szy5Ag{QV=3Jk$C4@dqgu4XOg< zbjq9JcS*`y(CSA49OKt4)#**PoCWE)yIzz_e6Af*N*WSNQJ_&3un6%k`L2G2Vf1}@ zsGxLM0~Xg@l;#W3h{0NiQdbXC!QXsGl!%j+Br+US<3k9}J?}+51ON-Bd=-#uv3m@F zJl!*i8@2T4W0crIvAl$ZpA2=f#$;S1cU1H z{Y7N3=V@)hoTXj;5W;nE7$t!0iW?k}eMZhfKIun+59u0pPyyU_(e0{!-v5cujY z|AG{llAGYzUx@5ZAYE=EsC+CO%j8ZO*UWbRc$Mlwj zclR>ii*mRaW9Di4E31&?gGw_|v+p!NX}DDNek|8Hx*KN0FVjr#m3ERqQw_eIi=S#; z9mSBtGI6>X1FH~u>rcFk%Mr`_#G^u zOknzDU`J>uzIL-!jITh&;ZIz_*63oZBv}R??kd3X78U}MGyOZP?n<_)JG&cJX9QP- zX7mE)QAw9Z2L-POiA5qMDzg`i80V4QuIkw-ll#)eNZURMk@pcUg&nmO&pZr;T8^U( z#%rNQ^mi=#Dn*}2LgyfOr`Y!wlJ8r!_T zhwi{!xQ@Z7wNC7>%(xYVV^m?s!Iq~R2ko=KOX{#?F<;=-H3z4#!`bB^3GU1(4;n^C zt98G!IHa)TzpxrGOAD_(N4s^Koj-AVYl14A)AJNGt= zhkT5J>PJ*ViQ7TB0{=31|I<=zc>>hw?Vv5mEJY&1>8Yaohau2MKRu{?F=>g_ke9)d zx6UGTlR=XRFgPJr=o;LL=H)au{_-JtV6=@uTi%GkW(7g@#{r)x0IY_t5bc_@%!`4? zU$lmkW#9tWD7|;#Hx)B_Bk&g6*oyAwVfZ~(1ccXgW{lp+g_Oo+(WLFkW=wiCU@P3RT zof#k}U%?*X>Tb54CsdhME&y<_z>@|QH5oOta(Lt2Ia4^S9RKlDZ~|7(Blsr@!#@(n z7wRYP^G-}Q0ldGR2E?S)pQZ?Va<$eG7*`o<362kjy~i_o)XIGAv8dXuRJ%`9Ik1{Q zJ>dzNL3K&uK3?7G91L!TlxP>{86V#E3zAh3-(Q5!wr z69;VE>1nv+&ouiOt}86w6sfoZ*Bii9F#)1H?sSAM7aw2;|HKhF@E| zZvnI&9FE9x<>L1*@yXCgMh;4ITT{Qq8?-6ZGq4)s39SxYG8#tDsL-s6ku`1j680sI)QCRzl>5JC zeOZTSHDmU>&ecj>MXQ0wQSHDF)~Fx)4KL+sVU?A8*o5V4YQfaoEB#?B zJ$$ghel5lzqT%AX12z!RWgqe?Djtfgi|IF?PD}6^Ak1%@bfi4RQdmL{y zp16n8n@CXsd{dYsbGC2|WkR~#-FU88Xsw z$+t4ZiQp;Z1Q-{#4*nR~!AC#VKR`Il%ARd>6r?dwC#A3kGUvF${Hh=b^9(Cmy{=;H z;QlNOHB-dI*Q4El6|9=`)9_fc0=~v%*+=A1cet87sG`3ls5Rk1pTuOM<0c^O7w`*Q z)iGLOQ8bu!dE!t`yl{v&Yy42o1vGNXbN@?DTHCs~NvCKX0cR1I_wmaqs+Pc^&ahot zS&?njs=Pa3y%d@;c38UY>elr~_G~nr>yI(G;v}sW0P6=WsdwXcCbHXG#826Bc~OHpK-0{5%@L|HtRgtf)?H5S0Y)ME8GHf>S&QMzJ| zYmqAYLam&kvp2xw12=bdtiE9Dtn|kKfIje%u9mr;E9Uj(#-R1!h=}bw5k6RH4Wvq6 zb-~S=_iA=Detzi&5x;qQ!V&C>4r(q+HeRgXpc%_Pthd_)rW*vNp)J-Jg@6#`kQvUCZ?WJg?^A0?R#kn&dC7wEI{CBJ-->LJ z?{W-xMYlxwrTu9g-$rvyEhYbMqIIOvtlv8L*?(1i+8U9Ak<@58%2sLuCr%uh94w5B zSUdqI%zAfNm1+EJvG4Sriyb-|b+|hdf8rLZ2m5BdXELjzGE+QX#ei52tXTY)IOuNS z;Z|z;(YO1pLMO5%ub7ibhD7h!Ka%&_<5JoijY5JxXoz-IUB^V!0X&t>!mV;~37JwB z+aMIiWa12_EoEFsltd#+k~T}CT_mB7n^$q)*RKYYL!u@EGoiIM|EBdAZAG~MyN3Iy z^v|nE+vsFl>8f~9VnBSHoYolQ=x8k46)(x(GYwl4C$CzmXxcdT5BT!zWGs2mW-3O? zXf$~cP|6Dv^9pQ@8j|{Ewk_`T2C}^r12O4(v0M|-xehxAO9KL$gk^lU_*Yscu8&h} zli5s|X`Z4&f})0YSS|0=k51(-dAsex)#l~T&!xUuZAs&pcfg?)aSq4NjD(zIZ=P;}AV9L=NS`a@j0eZnYgB<<@=#k$!D>5=Wj1_%tI~_bT#&=PAkXA#L4@grZcH*8KU(o`OwFh8#CB98H2L;;E zJsmkLFkGK+#KWnk1bx_-%ap>hJU`<@`;Ga@)fU>%PGLK%UXr%Quh<0;UAh`_$pr|Ib}qDeb>4I=nc2T;-v sBmJm%KSlPTHAVio4el@U2QrG;(*OVf literal 0 HcmV?d00001 diff --git a/public/images/favicon.png b/public/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7d92d2ae47434d9a61c90bc205e099b673b9dd5 GIT binary patch literal 687 zcmV;g0#N;lP)ezT{T_ZJ?}AL z5NC{NW(ESID=>(O3&Eg8 zmA9J&6c`h4_f6L;=bU>_H8aNG`kfvCj9zomNt)?O;rzWqZs0LEt%1WB218%1fo9uB zsW^yhBR7C(mqN%GEK9&msg0~ zWY?#bf4q8G-~2KttQZ($odJvy&_-~f?9*ThK@fwR$U^1)p*8=_+^3BXx0$i1BC8XC zr21u6D5nVK&^!dOAw&|1E;qC3uFNj3*Jj#&%Oje@0D-nhfmM*o%^5f}-pxQ07(95H z3|LoV>V19w#rLgmRmtVy9!T3M3FUE3><0T8&b3yEsWcLW`0(=1+qsqc(k(ymBLK0h zK!6(6$7MX~M`-QA2$wk7n(7hhkJ}4Rwi-Vd(_ZFX1Yk7TXuB0IJYpo@kLb2G8m)E{ z`9v=!hi}fOytKckfN^C@6+Z*+MVI9-W_p@_3yyR#UYc0FTpD}i#k>c!wYCS)4v@E$ zchZCo=zV@)`v^$;V18ixdjFMY#q^2$wEX%{f(XD8POnsn$bpbClpC@hPxjzyO>pY|*pF3UU2tYcCN?rUk{Sskej70Mmu9vPwMYhO1m{AxAt(zqDT|0jP7FaX=6 V`?~}E4H^Id002ovPDHLkV1hC)G==~G literal 0 HcmV?d00001 diff --git a/public/images/hold.png b/public/images/hold.png new file mode 100644 index 0000000000000000000000000000000000000000..2645b27abaee4a3beca7e525323764b58f244f32 GIT binary patch literal 25795 zcmV(}K+wO5P)Wl2OqRCwCNS6fI_K^Xq#+`O%=i6^xz z*SupFAF>M-r9_EPB0)h{D17lnsOKPh2+|57=%MH>il7G{3bE`WJ%mw|P)KIp(M31K z+gZ=6b7sEz|C#Uq7x>RY!gab|XXq=vU_!YwU`;OE;P~`3 zKSr_`FA3ZO3DOp_-w9Iy^zOY|gHA|D1S66lRbt%*dS9h(ZYT*eORx@7O0u7JM0Noq zSUE1?k6GZSpBGOS8USJc$t?&9!2PyJ_s(nx?i%le?*ZL2YN#Qev6ThDrX31x7=HD9 zDXfV&`PkR*aD93Yo|maAU|9uO)pfMzmbq&ftB57}$lTTpWn6&Vodjo(Zq5R~g|`j( zj81>P4>el)RHaqLa04_CpvttQs=5J1qTL?aO{g-^R0eZO25lp-#nr(Mc1TzCLeU=mi% zXL9^KNRkAm`63)uQz&50qi@#(kZFSZ#SGXI1`|(mQMD`4hyzQr5QbB&^MEow1THnp zsHc3(LwxBFW|L)1w02{4MK-eKWBx!yHFHlg4LoX44q!MfmP4o`KBpW%UY&zoXop~* z3JgFdsE?2pU_wc}09)4VLMZma66Hm84FxgawWz4ja)^j3CHrHxfV4}8D4h+n!t(a6 zcN$pE3zk=nGvLUu2!OF|qR;-Ql!-PLt-SJNC@T`fdtt%F6NsA-Xx>X6Z)A|wdQgO{jFM28@uqa_|XbSNkU ziwcBwqk|P4vapB>42vwqU3U8;w(hz!>-1)vN^@szVFTN{eIuyXIK40 zR+0^92{mhHV3H{Y=GeeIjU-wB&&m$|jQ^ljg%MC#b4~hvMm|8;!pvNNn9UfgW_*Ax zz8U$z5)KPVV+YhP&FV^>0x@~^^O)C~d0asM=41nOaSf#8?xVCbp;n++*noDux!wb- zRbvCP&IU5`Tv#W02G|hIGF(;dK_ns~R_cdZL&@#L!uc{Hiu#UduBxdwz43`--hto7sto` z+uHEG|5OdtMMpOFUV^na1kW}Lt%$kEs;uF&<>`r7*qFvX71Gy1U@4mgak)7>;Xi&b z`IfV%3n4@0%Y-S98l5Y;{sB}~)w^LuNgyuA@v3_lcJG;39#@=7z_iq-KYY92j_QyD zr=m_=xYUV?V|^I7^_8nGa0{4<7wW4n?Xknx1Ox|xhwlLIxeoOE{U~W4Nj{&Wa(UWw zIsYVuJI1fUo9+&IgjcGP4D73((lWOne464+FW*F!mQX@M5wO zQ*_}^hiFjg0toYtatkP$X+d;4iqrIggU9o6X1Ek5-9glq1Ksvs9Z>KblC%hktSIo8 zk3f0}v=zL;wPzu07`ll1`Z`EsTe#XY=0r4}aTTF(81BbMQL(obit5Cuy9-1R&|ED* zsBMKbzKK>1zBQQ%&`M_G0z2z+Vq$azftNrAQh|@?W|yejg~S7;on&@N*m_>uwW4J;kgXK zqA`L5#LqWSvZG}MyJM=yd*~aKu$B4(2S$ezMrXt!fQbzdV;1eUEgZhnaUTv#1WiZ2 zqo}ZXksV7}rf7S;Gwt0Ga;803Q{HZhtSO^jL_^^1ugtOwP^7-NrEHQLq> z8dFWB!Bi6{DN<=E2;EZHB8$N8vd8Q^-|WC1z{1Qji}iTfIrhy=-n{?)|GoeG=YKJA zfSfav=*cm&_%KQ;0AxQix6eH?27S#Cqlzz#Xu6zvV2H(Nx=6nw?OLLFMLx}?Z1efqz{3#bto{C$(`@8`;oo7M9I*G2PyOMM?itkm(a5S&{0|KtL( zr)N}v)8#^XmKRA?Pt&lpxL8#;=pR0c#MPTZ5jdIINt@uBUQiX08?S4++(;@x65P&U zv@CesOCo`PIBfZk702)fhSh3ya5xfR*!D>D1TO{x%t=$Dr6R08b(#P)L)IrxW2@ql zwTP`|hvBRh5#(8cG=;Z(80(? z*q&o?CV&dLQ^Yx$b!mQ6RlIo=MR(XK)oM6&Q`Fj!(7co`CD!GmST((&xr8z?asQ#ZxH@LU>r zvjpgh-cMwK;5-fV^nu$@g^aSbNTi-}oe3EH&WkcFTQyXB| z2qw`KZoe3HZGggNG_Hi#=cCkS;93ck(iFl>Q}s6MAE>Wg5dxR#uI*SgDymN5%U0m! zd$XXyI5Zj-_S_xxh6}Q^E`0KFQ30jR)_VwUw>z+b#y3U-^;9})2iD}kn4J$x{t8Sc zn(?Wl5<8o$_?)VI<_Pfa{=vX^m-CRS5pcAk7e`upk&*&b_BW%2&Y9!1xcH_C)eq;y`l@fm z#Rf3m)?j64xDD{Zglp`J@OZtLoS1+`y9e52Hdt!VhmV4lKOG$%c&HpW6|8P1N@WD{WOA(#|o<%E@ zRZ9gvj!pnc3X;FJz{vYcTU0S$wXnzMp;o{OxEOjg~$E&4tOt} zhux}&vHpHpLD2t=w!Z{m@7iOVIwh)@qmIX>Efjl6M z<3M6NxxT*NzWdoYPGURvUf=7uT95L@*RkX4^SkGq@0{;D=iEJKb&ZE#3Oa%w+LYl99$KC6*i`r13js(@fu|Aja;80&alHRvpQU1KZ&b2^`- z5@)T##_1E@Geu)rqBn+=G!16X-$W;f zg@bGPAcFk`DC~2Y13<)*UrI-KlT4z}w6P=tjaC`j3qfhja<=mDQ|8ehx#@b z!gMB(uI0ccc(ZvHNH-CH>U_}3a)rZwTSBdxG|dQR8kl*o3SB9jHk3Todq)_Y-wH>9 zO5qFvK=kXk5vR0U;M{PRN$UZoihPDaGY0^YC7&^a#tc^h#nVft9BwppD=q}Th84Fg zk935ggUB)h!63T3-+`rL8#cBZhEcci089nu)(onR;sU>iXK5W)kod9~!kibI)r#h( zCPpic%zI-Dmv%83nj(HjCp0O*zbU^ z#f<=U z0B&2i4*h3?szjdSv;mM2S6FF^uIyj@`d}}@Wv%$sP?F@wT)c`P9h&>wchsNFgKDTO80`VP6kFTVhM^Th}Nq9owK zvM-j0L8@t`w437qqVkf57h3 zKt5%@fLi@qzFLn1S6;%kNf&y?^YE_Z#kyrRu+m6&c!-W~D>RDNP1I zW98_m&xcSbgk3woj}bcUHC6y?Z&Iqsw&I0BH~unI0y~|)HkVG}JXM8KswP&d^UFLQ z20-xOXH2_H8HolP@_qnO6j|xq!=rn!f1K(!ue6$~L$!SoYV0EJukhkOXa0#dsC^iu zQ)Bm|gH)e+)-iJ3-9@H_N*SvhUBd5TQ`(Ll+wt&AdvNdqWi(%@3Oj)Yt_R<#Qqtd7 zqv}yQ{XKL6xef=r`j_`DnR62XVjvp2s>YPfO{EMbKA5>Nmkn9=GksTXP~fAYe{1<; z(e-~BVPZoc_QjH?ezosUcw+TWS;biW@CiJ)_%!a=w24)WDa>Dbq7uz_6eO$=Ly{^o zOFzwL&)dEN$&69trD&t{t#vNOm~j1 z5!+)MBSHStkc68-#$QK1|1R4QDQ^u$njz>g_*&J|a8L01m<^8VtC%SAqyAVk!mbo| zDx+NFO2>iFs}#AA=5ER)8v&4W92oI>*$rxPogyq8m(l#GSpalq0Dzg-!Nc8$u9M~P z3Udp0eJk+hgi^gh8xu=49($RbB$juwn{zsK%F#?}L= z6ikiw!{%1kNJX~|5C_f}dhzwDgdL}_=w=y3`7Y#1Zj7BPRn2@_5+Ibf8(TarV9tH} zl#DbbDQgb+(d=5@(L;Z~7jFe2sZ5X43xMKUwq?w%RmyV``1VJBcZ`PQGOU6~hZ{LG zj`;k2qwtGv>U#600+1DS&m$kOo7^gW1P4zD80eCj1n{{MDi(>Rt_XEz0|24%Z^Vwv z>w$(Z+2FrGUGO0XE_fY`%q!7ig4Wozwpna8Pfou#Agd` zXJ}JZ(%4Y9pAvE=mJrT|xU5@ni6OYCI-d}neArFi`Cc}bAW#EA`k-bCg0#5d0OG!3 zV$WR}5xRzu2saN2;#B0{Nk)|Sn0`?)gv5j6k3|lLB*@!|f_;I6a4U)Bei@xNiB*%u zp1m&2MkLX05)$U+VZU<)8M^hcm=qHPJcTj6VG}^9vV-VA<0$tOSwd)b@X7QYkH5w&Qh6Ir}1H3t?YvF8` zqbzj>#tQAE!;kd?pbwXD5-6zqn=Q(sfI9_s;yQOcIReWW4%S)~oJ|BS$^@JyQJUb{ zzUW)7+xOwwSBc1(wYbOM(+_}XivZqt(yAewhdx7)^23Nud8VIe?*9PL=1i1}IJw>$!INuKiG{ z*g)xNFN6e`Bkgs-;m}BP;Prz>#}5FQGQ)`P8+J0#YN$Y~r9{6>?(N6CetWIc_3R6t zeZP#ap1M5&dV4l=HmA1M6Xr5t*h^aTmF|6!-buV(O9Iy*UY|nzYIZ(LzSgajb^N(H z0&{B%PEG$ zCii*wCP+0!8m8WDM&?izc#8)1MuU_@o^J`rSz-nN>_cYwuuR4Z1wM*I50}7xS^dNa zfyM8Zk+bWdHk#dxhbn&-6^?J!qVW(X5ub6McIB_~Qw>JX;=P!k%sH%$_0Xqh#s>i6 zL_LezZBpo0qsQ)7Yef`YWyg@8%Ss@Nhufh9nmN>$3#dPBhQr<@U7wstFtlzf_;jB{ z!dqhn02d#TaUuOAJeLX!eaqJNFjFp=Pc#@bC>WG30mEjoAdy-Cd{RNoDuG+#hp5s1 z1P@5;N3uIBh3+;iK>jFlvs5f-md*%I>r zkl9xhdy=>NR4`({<*!IdNk(o?jwD|*=mi;oPb66<2Zf*AlbiCY?%R3YoQ9$lgP*%45Kj(!`kl_#V(%c_RWeO zm$K*<0SPR&{Uw)3A)tjzPhs<}UC?b>i==b^!R5E4g=h{&d=$PsE;xYbJQtN-vae|| zato)SBu5vJ=q#4!lP|2o?!tv=)8zwCHdWTI%rL2H07ZL$VmOV|+V)Uqy9>va`pXH&4m!Wv20bfmA3QGwvD+y?!1m93e z>uO-cM!<9gSV6hK|0t7MOKHfwt}mUDhcGs=seD%hcRjQacU6qX%9Ss6D2nV|TCEnN zrq4tDz~5ol7fq4po+{;Zri;cE{Qc?q7(S**A_|*2L)@HW!LoPoW%lzBD4Q24ai>zq zuc7e#;wDOb2XO1>?pfxv_uo<@Cr;-QSe#21l8v4(1kCi@VG4vlo}|a70~<#^59@1> zW6AtmBu>LNjYfm433tN$(dx(pfD;u;(R` z7dS{cMJok>nTm%G%YbcD9>E)LyeTC%U@^WK2nG(!M{V(6P`;@pq9&sh3{fEfs4o@( z7z_qVcpmM`!WjqnsQC|Y_0G@9=jvUoy*jrSR!c}H0#;CNBunKBa zn@;1W^FPAQ+c5Et_1IiBA2Z9!a3$3rZvQVOKD7|2fdI8&mD8I@0FXt2uI(TOWND&l z4)$oCogyNjC%I zx%1noq>Klw&O7=HMomlZp3?^{2D$}FO&{a{om+2g$^)ozu5C}tA^o>xrV$&yqw3JFy z(BA$|g?!%#BarbZu)zK>?zsP6x%a!bZ$Ewxd5J2hHBlX6DUTljgqU|I)RJjij5FMG z9n7_)v;?2>>v8j;E3kmq5>k@vu)Sr50GBH5|eJtfEY&f|K8HK z6kCBA$QdNG?Sp237h zvluoh$=B%vUuRE1#}K-A2x;_ADL!?ioj|<;0CkcJTvA#{01!kS>bR%i60}lkCa1#z z!C8wD$xj6Y0NYH~Oq|m`+37VE6%|-rWy6MHw*f38tW(y6G}M5YOzHPyEprvKKS18b zS-0Viyxo{P*KPB|L-?P*S_#d*XE2S81%yD{PyhgG0;;wA#Q^}eK0t*ksQp8_cd;g* zRm8U{51)k#K0+`v)S0hlc- zwCwJI?&o3u-f-c~yp*TRI5QL4Y;7m0JCxi~w8jbFs z9->>%AA@bry(k<~3wfM<*Fnp?GD$)QYd2RML+Vw-Lm-go_uAN+D<~xfRwXF+{May0E|gm*1F3U3 zeE;=ZXlQ6aOLGgXRQ9vU8T-F~AIUlA!Kv-w43rBP9MZ=2yMA_%GkwLLGVjQWPNiJ# zQRe_5BUE38>!)m^dt0%+>P9rQl~Biq$0ztn@x zS&?jeA_l01jsQ{eF-ZOmLf(ZgH5G%WUw;vB00f(RHWCDlfu&_$<+4B3NRQ+B6f{(q zz+8P4BJb!(`zkqx2?G3PZggu%X<=iU0|?ftFp>{PlUXRs;yG0-vWD(~Sjco40)a&a zV}T*8Dk&(0lPiT&mk*xBjT0Q;of&BUZX@y{O#EI0fU99IT<3QNIA@@&j|-ud{RiH= za{73NmQ`A<*Y%x861@kKIt@XKs*cCC&)xTZJ?Z3CY`@iZI2+ocb3Qq%Iy)c>mofTG#6QvV7J*p;dML=IIa)i)!xnQEYz(LCDD=D{kx^)Mc<#QZ?F*6aV z$te)2Vt9lF6X$((ql6JJRVXR8B32v+7B6>mfze}|9elnWO+}V*l`>?j^peJ*+^MTpmYiigt8R~4f&=WX!+XEjebL!p`~eQ zKfa+2-59z|KPWRTgi@Hol(tD+D20-cr6D`9op?!X$Gc=nPj8uX-;-p?cD%@XvgGI_ z*H6pSeNXrN_dn-c-Ft54e?~Hrk&Illk`zzUx4VdChU(5VcZ}4=`MC!BRp+*(8vrsJ zNhMYo2<9ZBqNEK3304kf2R}Lvxp*+jjs5*`)~^JWq33X8yALHO$KBtwY-Ex$%>8I} zmL*CrNdbV#X@<#W8mD67Q0_?QaPif_TgP#oL7B;TYrKRz@_BL~NUVxShy}ip9sqDS z#@%@FTPto1ZznQ)N20Db_VWb01&S!84+Chaw^F3>Mm&!*2n3^@8wdNDbMxtMeYyi+ zavCpr_YjYtJ?8T_Qb~OipdMd!V+uCjIDC*q1x|5%Ze+8#ED`!Ko&k8%11Z>eldoVd z>CyNk0e~~rq;BISqT#s-o-(<=>KcA^^;C_T+KtC0r#U%8?r9k}=T8SyoB@PXZM;e9 zA6z0Y#T>!Z1%O(A?IS7Ncm@+-GN_%Ka3o+X191EUDcpETy0oF+Wx|nw(E!Lv{a%!M zj@kIb<&^aS_$l3Z=>@7J-<5i1!<9%_+W2&v0GrhNUB;EZd@uzWfV=17P<8@9GO@2b zp-6x?762T^U&h*ap~9tW8Ru6X82~Bh18|vj3rS+MWvK~(i_-R|pBMrFwehCSnWjw- zG0F#cAk(zT6Y6kRrW6B+XOAt@c3iv=v74;m%+`^SIffK6#_dF&c2WbDN;MUXtZ#P_ z+ioEzqmyw0nis%Zo#U<})Cn56Suf3*bOs>h&*rDyl*1XA-FgW9i95LjiF>A^cj5O=dT#st{l zx#Ou31qgXHndM(2fmI-i7n}wGc6&-h0c9CAPmYL3w_Z8v41ml6&{5^!FJ1u8D_Y@L zhA=H9tezs4kEDu?N*Nnfw3Kq8Orpg%+@Fw0AkhF|smnHI)O=xC`GuCq&S8=t2%Hz- zJI5o?#v#xVtK{(G=%Z#b=&{_}1i@W0xb0J893UfO^qI(Cc1%}bFHrS#aV%YIK+rRU zvXpUNLCX{x^vDDj2?0?CZ<1hlN#GrY31Qz10MbBAW;1xjhJHWGBI<`MtJ+{%A^?zR zC$Ki7u8}3!cjqwiA;$DtFvyegsYpX-TF09=Flh`RxBhn6!I`AV7&fXHQ#eLp949#A zuS+S^5=L@D6O}r0{6^558XlrBb0AQOKQ9L?)$H^`VDL;+@6thJu1F72)dCc3L!>4_ZycXIWbnr2H=g^p4L)q(c z>@K2_bLFU_ME3t3yxaGP`w557#h}{_r z;JtxHlXsra8RiDq;Ex82K)?n%d;C(USl(*n^0-Pb^P=vP5s8wY20>@9XRe1M|) z)oBC(Qx5q}5`{gc6i0?SDWsEIE+J=@Oi?ngZ648f7-%~tk@KhGm9g5ea4GDKN`Ota3Kl1*Z;*1ZFs&0TG65c(;+wF(b z5rE4cNZ8j(%IeKjouM->^r(r$oOn8am=8ox&m6R5C{&>&3y>s1I}VZqDbE1@Vbcz`OcmtG~cyB$?NX#A6Tyx>FtinMmUTO6_z39j?eo zBFrLqUL1(|qJ#|vv)-?`hwLDl+5pf^K+i?zNW}9gRkT7@tvVpsFTN&Kj{^KU16YdZ z!oG4{dYXWY#DNZh2j}KtD+;2r=GMdqXc^ajN<BQ`cX|Px<+U87}a+tygmG<^oIDKXr+JZrFd;SP_!*;CNkPEXViT;7g))cB~#Q-TM zANU@CN3O)Il46uN91*3D)-&@9@{yO9M{JU&Mm> zl|yWOSa$6AEq-4o;7IWuK+YoI+Nd|#`jd7#rxBQOXb)C&yoCGj{|qchY<^euMA& zHsQ#}Ncn4lDwbAZKJd3|^&^%7U%icvR{_=QR^io(Z{yzQ>hb$mU(=(5@IQKNx7#qi zxEy_Dn~4d!Rst5R4Ju!YKl`h~sl^3Q1 zpQZPY`Ski)$iLg#2F$Dhq{ViP4W51WR}m0|P0X^TOVKg^VZ8Ioq*%Y&Ddq&m*Do4+ zuE5-iiYU9UXXHM6_AI`8ay|MN*|q0K))2M-Nxw{6IB~b2mp`-u@Ad=!ezx1WUf8Cm z3<#JBY$_&p_}{xxy>AZ|FIgP9mdRv-l)D&mF#Uc;OdbP_VR`mMq^19wfnFa55ckZ~oaliU9!lL#DvrH2{G2^S~vQ5V zJ9qBXc_x(q-$r3!SIJ#CwcnfU#+#7cNva7@d_9KD3~uvQ!j@|v7}pOCt-P}HAVlW| zD1P9fWx%7gy2E$XPl0R##g4uDLR-22U1~uzDDOf;-H42 zVy3c=h-~iy;P{RcSigR~K7l&~0^X7p7q-=l0RZ|?rZG0MxpyJT6g4!UrmNrQ_v3fH zpM^-K|8DB(+uxv<$qeuzyYHcRpI1DP>R(4y-ZQvt>+7&M1L$kb#$TWQ2pWE|66Wh} zfU~qp%h#ZgAD~iU>3X*>p|bnhh~o7FhvMR5sC$x5p9j*00gRG<(M`Qy(bm+5G4S@= z@1V2)Mz968|MNa6n|ivkGIXQ?c+wUa$j!E?ajG7jvn&u4rmao}= z1q}@i7{Q@ZNcl|Q)L^OJAEcWf6#y6rW@ z?yDUv$rI2)r0ciY2>>m+6R0_Y`Z3wUva+NF0F0a%$|i8AF%vpeoIZU9Af;Jt!1u_+ zd#MMvc=YEC1afN=T<)GBN@xCp63~m;M1THq7(oKSa}+tO%>kZROith$Hb)!v?buOmOe(ogl|BVzxl7nx**w9_l=|zNXv#=#n+J5_4RY;GBE0n-rHVsK|j6 zy?!@ZMFH;zx?#;Jg?mFG%v1*}GMR_B?*JB%Ur{FR!K{)n0CW~;K7Aq~Lm|oZ3De-W zfYF=7Vd;Y$QhxZjeED)@nK~iSH~bJe>Q;-ccw49}S-J$5mdwE6UJhA8Hja4gIODgX zJ!nR|XvKV+2eR3NFI?w^$po!b5apLYL2=hHV7~GuI6l?~aG!n##q(?l_43(#KqEVk zQ2?kN)&vB+h5@AYY#n5o^?sQ^AXASu;}-G9z*%4?G4sxi0};SNqVYScfWzJnH1(?3 z*W<#0J|~WQ?P%~>5mY!_nx|l~s~^`_*znxP2>=4nOcB6R(uK9zhqY_h#wb4!@Wa{h zCg!eirIiVS!bvR$)uWHf@&T8x+I;fRy*hQsC=HsW+2nU*Go) zG>C5Wbq3KB6j0>|U~6eBa;P^io57RPoml=$iXMnX>OT)$_bgCEj^S-LemF|`yjI+Q zzZYvZ7~`x>853aK@R)w?BInmM07wh~vZ6hHVDm;)`}RO0yZt7y^Y`k2W%;@}kc7)| zneD46%ynRfHHdt(q$O!vJyWDQs_@XOjmWW6z102{+sUCMAMZ2scVKR1Wkm5jfnfiO z55wlAJr-h6CfD+=Uh^c)7BCm*d&1THPq9YdHt zfiiwS7IHtwde>*r(fK%@_**C5KKT}|nN3P3rg(?I({T!TaOOkU{K>mDrH4y%OAG8D z{2Xg<%r#`VSIY`((0srt2LL7&IsXSa*b`%)tE=bZ<>gIS)Al+*^#3C=1&;*?FcjJ? zasyk+fWF{bT#@%hoaneiYxFA8=L-jblScqo#|PL}_8olgbN9!1{>;quK6?o%-`IUJ zN0<;g;eiLE27+o6*%kWu4d}u38l?w=LGri*m~MIw1-AEwcL?nd;;8>|n5_f7a@w>) z{OYDcJpJ^);;9v1LCZh#@KctRofQH?0UdurW!L-koEYM*eFPBEhx?Rm`1F=rFn!u5 zV}AK#_j2O#60E$=P4q7$E8-n22T~OP36oaylaN(gDEI$>GNNLoh=;19C;Hs|9-OH1D-q2 zY&ow#W_EpcX{mPPnRm~Jojg8b^Sy1SaOANfteE*EuDB!T0{6wG9RnBwfQe+R7>WXQ zNbgsNF`lizZXVINmYE*MZ1FY}NIwcGo)3W_TIsPzR=N&81@*0J%vEp2`g^9q?h+E} zs#|Z@~LGFGH|~R_783JO^6|0OjMV-zp!1RXP~EUyw1~+K8X|(hLCOTpM8F ze~IS*Sc>d#ys6n?_A4J(ybyu8m!tK+Z%4(_`{*QDZ&}XxQ8S~oH~~{T;dPiPJrE>& zyHR)354E^I5D>Ma&mVxF%BfY~g1P@3crpRJMK8=81kQ9CpiDIl;4lSE0B^COx1$NY zeI?|uv0XDI1cQ>6+o!dyjp8|#&QT%^p!(J%Z!3?YAMORe%zZ3nx_r}#4Ezgm6*TWg(lM$ z996zV&sR{-H^H@TB^?#5@LC}TFbQ#LMNK&x&=|m+Gj#xwxD($ERUZ0#31mXRZ- zUY1IgIfL33^G1bwiPfDyL^FU607Y|{6yNzNWxtJH6OeW$z-H5( zKVIfz8afg$v-BS+qR}L{9|uyM0cwYB7+H$DLN>vZrDEZD*C5kk)p`r>N|OyL$CE$M z!1;ia!T_q+ANOYq`!{2_`By^mT7;8A8P@kEDWc#E2EY{EGUlS#FzP_6GXOIIHR;8> z#-d>Vt9H#_VLw}6=!o$1aN(cRf{MzI-U8liP53=!wvIKJ9-==nZIJ|TOJG3h+`v4B z(H?i~*@41!ca7>}UAzEDgAJp~uW8ci@N`$8#`{Cf?q9!)_QvRBvTXHo4g^j?Nx27A3%qbz5Mho11~8#;>8cC>(pgMdzaZt$ z1W(E~@iHl0f}kB0@O1Q^RdC>(fQn`PC|l%#*lCBa*@;tU9B^8DP_UpX1Az1n9;~?Q zo5ykFk6CDZ&#D1|=j6~))s8Im%by`?w9nQ#n6o6zt?;0?%|KvgG?OI#D{iv+=ds|! z9#pUQK$Z1zfS%I=I!*wc^~%7=Uvz{)PXz;$P-g&;EC9$9_L}O3IC<_FDzb8UlrFVE z@lx35F`>0VLRYJwqLp3*{VD`AFf9Xs^cE$smK54VEYLDyq{_tQw!mSrL-h+d(I6np z-ko89sUULyEm7fc9T7cSm3(%@cvTY32#S^r0H&T#P)_PmGX&T4!5^J( z6B80UgbA4hVg`l)6G%cBSTZ{k!_4wqezPoOuHUk=FfqdfW;l{z3E>DiNPq-LY=?zh zB(~#2w&O#VEm_um^<7nOzgN}W>ektkTIz1;efHC{lyC^_ib+1@XpCkS9RfWH?-H5N^X zkSyZeMdWu#5Wwt!|6eHDo#o%ylM61*!D})Xpw?MIUO6*Z=6V`Jid()!ByhhVfZ5Rg z5_Z_!DinvDpI0Ew)hm?pd#+T7VvxscNJWy58DGY_juF?Og<}*aQb%?W3G5dHkY~{k zt~@I%@(NM#q#`RCQl2g$6>-te?Rt7FhD6^$(O3cVrZXTrx|VmlSs@DEbWbg!eC6|c zQgP)|aoH$%4+8BF1dvaWZM#LmE1;1m6+>PN{2p?@^<5O65-vc#wEu~#0`#{}6ue^m z{8C}AFBSJQNUMHd6xf>|7J#Je76q@A?kCOhqZI;c6W^1ay6!fq@x`YE0p#UftGh+P zo1=S5#b!trgCd<)9*V*ZD0u>4r?2O|{>ntbE7YZm#fvOPZ+5~w?GkS-9|Umbh65CB z60a~%d6L9?nnT$o)&HuXRzOKx0PO6I*G~?9QSjz0pOSc&Q_Oe4-GCBz0g{z}qTnqg zqi^cZQM`}5OO%G`YX9b&m!lw~+N62*b{EpLP(}HyyQJmM6wC@JT?;^xcZ-52wDmlY zg1P~vYXO|Qm5G8Uio<+($I`N=B=NC?Po$xmvjbiZ=)%07($QF!=A2gF7F{1^7bdz(h6T zfYz5=VoJ+$PMe^#y*U#x^zi(A!DNlum#)7{X?X5G=jxV4ZEwlJcdn4^l>&LiqN!v% zFaigU#cG z>hTTlAJ@@lkZlgw1Cv>_GU845ebK&l&;5b`tW|t>h+-^cy5cPB!K!z+ATcN-)TWwX zk9I3&9W8@J0#=Olc5Hx%Be@S5af&Q5 zaVVLKxtiO2QB@{ci>vZj=lgg}NY$-4)&(<83@~0_X_AK)wiaa7qFT1Hm$Ct$DT<8< z$xWcM<)ruo0SFN?Q(^kdHLbmjP_0R@dt@+;LV}1Po{af#ElC(!@^^B(KSFkdlpw(C zu-P=Ist#Gvq4lmE|SavKzH z#pHyucF%r605kdEybU^XmRP!zeq?|6y!9!NZ1lf8Ki5)t4>*rN>MV&i@M&{fE0vAnp(G71QczGA6d#O7`m>cTN|XL1>jtM z^<)=m76dRX2XkiVct;laZHpSn*IG1n$g+vjvGIS8I&X~atTZEzk9lmY{gds>;WuL= zw#PWec!d|Q@iIo7gY5H1T+h&wzcbU1d7pH>xal)b_}VdA)3RL5^~r==h*_7lgT?F< zpCi*<`S1(XAhKX+J7U1&Ww0qQLl)tX3&4TTYZ_zG1blU?U@NZ?1dwy(%i2Bt@uvHx zb`oiP$MP);eNah03|umo)eP_&)~F-$5WtvHs^;fH|_fb=GfOfPv8 zo^k?yoc#TW%?w7AeEujA0tF*?Yh|AxfYENiq+Nnjm3u~HKq&O00&#JW$A#k#v>$h6 z^0@{77aVQu;v4y^hLFZjQ$n}`p?@iNFmfIX8=FDj$`;YMjg`<1Z;JTs-E=5u;!O+ zwp_9*EC3;eBpEhE2(sBn`7C5Zbs7k+iXzyU>?LR4e4?b>nFSfe@|6&GzB|{d@DOo(OVQ_E|126v?p5|j%`R?}%3t$S;oO4RvtP}4f zH=rM~DN|#?D4|U0@f|(_bt4e0ht<6&GK>$MLA6})x6F?5{*6Eeuu&XGmbeiN~jvLUdpcDRM5h8_H3^rAV z$4z9SYOvdMuq9P6CemSR_nZ?1Af!ZCVVru2A|xbE0KC5e@0L4#5|YM0p3#gb%MzRp z1zOyIEXmL`&Fl&+^De=Pv#8$7ys8A`Edk7Q0S1H%Fb@fdP030akw#MxKUEFL29)wJ zWM>?bD=~f*$aLylN9@z+o^rAaFpoJ2sD?CA?)M1-7%c|{F!FUtR{GC2(;OM3k-NRw z!-6xiD%muEf^1JfBC}n}?9`N_0hAKn6Ht>Cv+~d+0I~qo;7VZu2$`=AKAYWB+3dd= zBW?pI7$H~En~_FY1q`}5UVw3TLNdn9r>0vY0eLHdt*i>pYi^os8h2p<%tTBV07qfO zLV`Dn7VNAvvOZDw2d08a1RdVoXG^*p){J3LY&r$<{ewHuPy(}A7yZgW(;6FX=%rPv zgv=X*DP74pz2V)>f&fIxo#m3u=I+!5%y*I?fWo){X1Pnm7O0wIbB{nK(4Pe;rL>;A zR0v?-l**(QgOFmfvsqE*YWDors=0G_g#|F*oJ}^xTd*R@J1YTX&hk@BrT3uouNg2o z9ze*%qA?f3dMySLtB^2U2x+xo$;mqU;;YPptsn%Ca^4)VZ`JZSi3dTkD0Fj6yyuPg53IRRMqHM z(&$3fGDkt5y-@nNloqpHKplbDf_RJ}eHB_`A27AMX%N}%2v(M((dRWmKk9NLCgWqt zu5oPmpt;ZI^;+bS+;4INSbDp&YdN}VHez7lQ{dCCw$M)Xr6*xK{RnJQ81Dk_3$3nLbd;f%+Ffx7pazXy8W#K}kg36d5<{YEG1H}KhcqoqtzbT1V$FP1N>BTYYkMcnvWfAlf_I}(Yaz5Qa=OuDIbD^@JevI0hu02@gf z8G#eiisj3(Y}qn|!yz1(KZBt)e~HkAr!n;8Lr`rJuDZn!r$@;FGG;Gu> zo;=M&YXS`4(@JA=Tk{Jji*KKD`L?g!BMAZ!1dtVTt$(QNFW~epM?Go%smSxszkp}= zy@aO9U1;-fH32WJpaV``NoB2WBiV;Reg_f3eI3^CJ*2?CKVSj>p~b-6?bd$2{kKE_ zZJL$dPlWLxKX#TvtChwZZN=rSKSVP^$k#5I~^_fQ#P#qar7O$(IF-Y5^SV{4{Epu0k*v z%=tY%_R}Y@@9-u3zUp2iJT6F$ymZlrCXknT0Uv?CHL2ZSxD5CxfwwJTl>~FvTiJ>w z+1h@%mO$PI{2@C2Jvm{RJoa~R3^rl;dKXq);VB6MkYod@Q#U)JDZv}2TD+Ty0O~~@z~q%AJ915&-vvVE zwhw`3IPhxwZmeo-NVdNx0Xo4s+^2u^iC^N`vj2fZHEGTUZj18ey$Fb;t&h^{=pYbZ zS4}U`4Sa`yy8ZVVz|V{c`0I!){-^?|q3voSjGvJ9-*uX|b(^cNnQ`izl;z+ftHQsQ zHh57U1dhG_VVsQZLf7{n#HLy&ZoT!E3=05R;Fst^wKX+x*zGvOzJQ9RzsI>FO=iX5 z;(90MxilB*#$qf$5y~Gfe$q7gy&i8yZ95y~SEs_ie*Hn*|8xkCyZ#a3S{LF3)&!lB zFvybV=X2Zxhu=;B_5n|y0+tYHqumt9kB{|O+h*J|9Dl6bat*#k7Q)Af2!2ndf2`LE z(O&DCaUzvO2>5RSB8VCy0NHXI{-KgWy+zmB6)wEe@YneDU?aZq&3{KU8cm!1ncz=J zRaF%hS68ER&ELXM+&J;d5TYTy_&=IddKG}BW&upWp_=Y^fWr3{e5>bO@Rc_r;18HJ z=h6g|>~b)D<*N_iXYsqyu9B@^2W$OJ!izrw{2PJ3jI{DC&j8ELkSTvXy%y5UAEb0o zHUZxtfNuEV*yjJe`z?1-oJe5P z4aME9JGa?}C$w$_0p!yKFx`My$XqP6R3xE0&>r574GlFDw&8Qca`1okUmn2YLwDn% zOyFNazz+jgkoNzJO{A4yAmBZQtOa>L^K)Q1;izvj&_x=3^}i)GmoyA~rVqi0cT*g- zhE>yk)f0eXcfuB^pjb645jg=oGX(tn9PmkUH)>RCBBtBlwA>Db!oy?@G!YR!wwwru ztcdUL-3nEY;Kx7y2|oVuyT=4SlH0r`;fS9B!Xp>M?OyGA{i9&3bG1%Z-ei|H*odUTjBTnlWXoJ!Jn~RMg(xtdzpD( zP5{d3-Pz}MbvG|xXlM0WMI zt4C>nB#EHBybNbNYs|F$9f{&rI#Ov(23m>)koz*>l4UBE+SFitxURtr3j|r9XUJTb z_T0I1xM2LT34Y$O$3Z0b*a-{Z*DeFTK$i7iJx7|C{Qa-p1ib&TbkzZLEsEa3lUQ=; z5#0LUzY8Ne4NaFkw&R|k!Tv}TM_zFvwyg}RyBa@uzf~`=q8xY!StPgZB}@mD-e0WvIsWQHeqho%kLJf%sw0Lge71Tfd@0nUFHz*b(8mYP%T zBkj)#Ktg(=`E(?W{&IGU0PF;+Lw`=@Iq}^9f$f@FU_&)GhUJwy;0)<9qA66u>3 zvwnnY{BalC7~=(IW6>h0>V_5^dF~<+$tA?~Y8+?<-cD|C3%MnJ%434!%NGNKl(;Qq zRm&)`)V0O{eUb>I?nsIaZU1~rbJSUZ%43fNsWW{0O zVaB?lQ!s}2QMmHTEm$YLX1M@^^q=^S(e{_G177|JaQ6n%`gY6o=YfwLb|zz=&xzHW zw?N$<#HZF>56OKVuZIp{e|H=qO-8-l4IjK1OgPaMSJ6w_zc1lHYq%V*bT3EKkPn;a zoaTg%3*_9teOUk%H*A2@RR)`CSs1Cn>&3JPg|HRt6c~9QTM|!U^X5(FTn6br0GWaQ z4e~w!=UyGJ06GO{BRcNNAkd}@3{r% zzwu3Uq6V#@Mzf3WVFLFby9vMqz+(fJ_D{J14Sw>YNr~RQ8u&Eh0ZpAA*n+nXV?Ns)Fy+oW?*x1JJNSY99<=s1B9;K2 zAb@``0DNa7@E@l@X1fK*o0nRrfB87jVb;R$!rHPQ6YxXmVh%WZLg-Muh{`JY{SxAa zb*~DWhRwb())DwO_#)^V&~ZBKzzs{{sBq|Di4SAXp=)utgETdHUT=I7_~?@s8JNJQ zJdv@0>%3258-f25_k7%%#%vVut+@TtCG@@UdAJ9gP+jl9(ly23kt5v;u!JT9EmZ>O zD~@c;zB*c4^)%k7Q0FKe`1*2g19`xBqI2bE7gg`W>L;6=wS-~{mF<-oJ#3S3J7 z{Q6a74GaPQd2V>PFSiW%BbDnvo_?#52=1!*3O26} zV%Kf=rcL~jF@MIh=bAC_i+kW6IF_6Xz@77}X2nnnN@z0B(T-&aNWAI3FH-cST+-^j z{||_@!d|;BIXiGJqd}&*=q8)!I2$7k8|T>QS%NMOtVECPGvm(qo06`sEZX_-IE_wxjvIO`V5kXfRs3RqOE==0lumV^ceCmVn*?$UM)?rsP zc!>b~WB~*ycvT${trl-w_!NG7=3Y~~H~K9te}oLJt>k|6()Ipu7U*u~EiiOX=cCx{ zKa7uk^bWIM|9D!T|8II@KaA%;Zkqk;-{C{W(j=IsIr+z=X+}*4HnrJ*h9^dqU~^f$ z6y@N~Dc1uOY9^q0PXNZy5wM}7B~Jk6b89x7j{H|Bt~Dc$kN%Q;oWL*lzl5h(-i_8k zqqUz`pcD9m#0%Rs;K4fJp>~S^HhRccCvE-HHmfPIf_Ualva>Ww_rMn5(=S^fzj3kE z!>@|ce(wh0mZ!-aivS-Xmt)UwiB#IHntr% zCbg4b$Dj>wSH@ehF7_JMC0@hQDxcXeVg35ck?yuM2I!GIs?&Qo{u=`RfiIiu0c?7g z5596WTe||qVF6_1XFLmlR{*LGeY%r@?js9er?3EKgW2Y2JV2NF1!&T;2;N>3>(t5p+EBU(7{Bm#nm-@%A#exwy320{AE3ft8f>XiRWn*4jF_0eH`7*YETh8o-sVbslWsQ zOtHdb|5V2V@M*zjpFCJjz-N>23`;O{J#z`}wO$0SDw`5R$p~Yy{B80_zHO$}P>Fu^ z0|+TQFj0phqzB$&av~TxK4Ap-yU)Ce?$`bU&c0{O<-yl9I0WIwd72rAyacm^M~ z6BXKns7O4RrXB-sGVw1*Oj(bFT#vYN#V9fuNf0B!o(P|Xs=Wm_I}ADeDtu})QuB{3 z_E3C1f0nA`Ga94Gah}b(lb#5qL@NP>3y=r#uy4U{e2L1tgjll&@pCT3&%4auywXT1 z*HB)iLtT;p(`9t3yKr8;(}b36)1laPYvfD@@bpjw-OsiV2`tN7K4lhyOMivJ%Offp z+3?JvncL}o#@NSIqi}#_MUtk(*DftZ0^qEUfw}0HWnj%D1er|+u{7v z{|WC^Z-;V~H4_=1+RNah`E(tjgod88$KZPQe)s~v#D>eBLA(1N%AyY0p^Yd|$r7h& z7&>$b-i?cj3s?)Y%4g!DHVqemyR?OV5A%Cr5L?--P75Y0&!hW$bvW7gIru*RGdNaX zZ8q}_hXfJ?{CFaPcsveURUM)q{&yU%`73BAbX4_y7fyECv;t&%GT5~AwnQOusTYZ! z@GK!9-}X40)-_6*b7ZBo8B@3b(=OBrXdQN_t~h!YZ%0))V$SI&$;0i$ydMB>|4$JC zKW?|%5e{6A-bgJf^fsu{0I32t!ko;W{YM`N!4`DQ@$)Spb08lw^|=6>1p&;u3t;sp z=Tn18O1>Y}2DM@x91W-7>-Zr~;6tdbtxe-Nxtt#nf@}ic;jo+i`CD4f!`<@~mXw_! ztH1|I(#)>9qXzqBR4%2boSH3Tm(4Y?%n!+|0F(p)6yXQrEaeBfIYe52f+L6CgJyOMDl02cURDm9O*LHsKDS+0S2va_d$Gm)D5QXjM4yu|9#6Ga zj?(;@wef11XM()4S;8PI7_Li(I;7Iah;RY&Vf_Wy!y5$NhHh-rzX`+!ajtC%!ldE( z3-%iIFx)F~?G>E#-!^R8heC)p9p`g(k1kNj%0Q*842s`7%dp7op};UFZFQ`;(#+^- zN^mmJQg;DzH38;cDJbNtyIp9-E$E8ZBuzzW%rX6n#|_VwTM#{Y493vF2#rGE_xi}i zSXl)19YV%>Ju~bDSQIj^TLDs?feJA-829)2gdLfIFa0%U+JG05Y7luCkkwRRa5P@F zfbc366}XE^kqHZ+^n};;J9TH$0^q@_3>=#Pf-t&T@`M3aI0P*)145qJf}p|zm=>_6 z(s@EE-1u@n0*|k7YXu*CKJU1*`p8ZX6(ZTR05XFS?k^2nc$Nu?P032gPZ~D+7D=

;7~He_jWWO#s=zkX>%DNPkw@WKsI<_U8d>tcVGslndDOCj%|ydVsuq~skmdmgj@gB_)P3w0?F=$(SMe&YYs_qLMm$zrc{yg zOJ);bDOUmt7htwttkHr%Xm_%3=GNby6M#~+e#AMf^uQ&$69@}nmgOLF91pNOkTS+RMdEH?`R5YA=+z{TeFeH73bxNKJ|48x94 z*J2Eu_YfIGp%3*AkKh{%$YbmueGJ(nH8aSCi%e$0z=;B){qkVE>9l{PUbL*i4z z^<01>*Yzj_0n8o&SakrfrUr8f@D#0Y&*tt@oERMH2{_6n@FfeGE|~$zIAJV-1`4_t zT0#Yj5^N5`&}5n5k&U?=RbkQ0rlH+=tCK{s_Thm!%QCD;=(0iH;K$Dkllkjnv0f}waRkaZb~nBh`4LtH-v)ux&*gKTq@ z#Fr`6jzBqA0`fLm$+g0)1dN_~85)^pOxJ`tJ(oadW9rOV6C+}W*6W^L)^dDGDHeh} z&_%H|U!$fe&~+8MW`zl4pKt+YC}ct%%6Wkh&T@fmV7|>@;gisYIw+hgorjH=Y7-_0 zxl*vZYaMWDB!L*gw+*4bQz)w#LRooY0xLxt*NQQ8ZUIPU3xT_O!k&T6f&lX829$|n zEo2l-l?(XM|2jIZ!OxrdWZsd7w6&bn*fQ+2U7p;>%rt!4Za1)Ia|G2j3Fsj^^n^Of zA_#>Q41{b@RSgxZmtpyJR|*1{1If8Jppbo%ICkm>`VQJf4~L_&m!VW`tUbilybmt=S< zb$BZoTwWe9$_?NP!1P3V;h4px=mY_bW>Vn-2pMew#5O~=A0VwBA46S_*4!gSu}Sb$ zNO1d9u()i66hZXKh;&J&E0Bm9NW=_W>>)S75r>1)J3P(_^HxlqfP@7gWS)ux+Tf?H zO}P`YdIpkwfz0_XGSTB>62T~wK8Kb(o2n3iR3zdy(}j>_gRF|uS}jBnfRK4^4phO2 z^B$nv#!sX0H5t0CfQw|$_i&?r5Df6b(18~ ShBcP}0000)0#ib~lZ6mXAci;v??L?oXv9D24@?v_5rQTd#Sr`>VqyqJk;I`fUXUml&A1Ja z%)!`LN4xIQu3ax%+a2HcbR8>W42JQCp5)}TU(a{m=Y8Jiea~Cq|2~u(J)rG;yWlmI zO5vv38s%RDaO(7*!e+B!WMl+-yDgF*OwJ`@zVaG}#HXx8y_lgWs(vND>bsHiB1 z-EM;r0^x9k_w2l(5VZW&Dhvh#d8VMV(~H`gY6L?eSS%J)RaVG%&bN06tJMNkRgp}l zXuU+HL2rP=p(!_{0iWM5{Qhq8ygs7*L9QXd)5!j##NBSKQH&{0t#y05sI z56vFVt2WN4Jgm2ILO4jUPZNk;Q!>EHP7EbrHklEo3dbpTxrdi~$0NHZR`(6k`HORO zkX^6Ff|8}3spd2zeM#D{M6b~2p=VKR4kN~|wJ|r8(KWO*AnO5ZXcX)|0?{yO2n57f z5n*+Yk2@}-*)r;3EEdmzD&GKQ!K;}~yR|W3JdedGLz2?UgW1|~MF#lC2^Nzfn7%K) zv}yF!#IlGKA*Z0M7dpMHl*t3@pr8FD^7{7CMM2qNu|nDOgWPlQ!z0pFlYRtqQ?Z=P z&}CDKNA^%T)O-vrk6~(B|*MF98D~NYi zp~&e$YiElLt#d>cvGxfekcQO^5Q{z|u%VAMnAT>Ds;3`SC@!h`mm*`u zX6x_6STUm$HPzL4?~h>#fQl#T%IDB$6$@t!LTg$t&pB868dX#?xQTH(^Oc+sWG|Ae zcw_;2C4#hdzVv#;b+4RkoJU8;1-Y)7mjvGj9FB%U9~R66id@xLQZj@xVmc5A5MsNW ze6iW>b~IBajn`a`D!pA@!l5~GD6$_dfuf^$=a(Q-aU{b+^DH2brlW{9%*4ryuftPR zB(Gp)G(7Ani&G11k!F;Q`?0eAar}DnXYASBh$<0Bsb*DjZo`sUvlLF1@zdWzfaa|L zYL`OYdy6b8O*^B6!i{u-)L_n+eW8g&$1NIr>U27#u6lcW(bIm0QpEtV^)ak(co}=% zYLtS^6KS-yoflKy;oeOyVR0B?v61Lj-UmhdSn1ko6b53zSzm|YgH0G&bq4Jn9kO~d z7@j}%6(ZlF;*l+P;kkKEY+tuRre;DcqO05^yDPrjt_nc>NLXDJfM-5nEx_ z18g(Udng67-3W7KA$sQSCs7(?2uM%Z)|8ZjWB^W@Hj^F;3rLVhUqqthAw=`pk zuY^HKV{vinQjH-oc401bblVb3&(zdZ@0yf>&bM8{O4OU}Dy)w#hj+(kP!c9s9jbgb zIGBc!;G3=VeRYRCr=1#ArnbMoA4Md}Y-`6Y8yBo9WfzpHH>dYsNbnz*bLZNGj~Z8Q)vl`}5{F#^dBvGDW6GPqlxYZuhtkds#r@Tk zUN8_2%eA)W6UM-f;$w+V%MWC~_kK{cF}AZ*2=Dzu1fLKh@w~u)Vf-xsd*&J%R~5wZ zxtnbEd9%BDr)}QVwu#zA0!6EB@Pnn%4=ofyEFuclKp{mdRVzvTAPTnHQiLihq0}Np zEJdsh2r-tXFG(6SO%oC~DM@S|n{2YX+1yQbk2AA-Z|<76DSi+SIqc(Z?m07a&Yb!E z@Be?RZUnJCy?4d+8^6~6s{%!go^N6KR!aLmC00qVL>pLAA5amU5=rryz;wfg2e_oo%*+6% z!vU0dRvm(C*KZ1gL85{9)S?N{WMo>9x6iA7kmb~^7K?^sbUQOvMh-`^w5ml&&4n(fGK&O=6qie)9u7z9 zxK6}+kGeXzTF|Px!AeAm1w%lDtiq)S_qqC6jk&4~{JrVHbUCt3+z$o@{uH$Gsa6X| z!W{3oV_FF2NJ4l*aZB6tOVP6z*NbaeA)s2F>UJVz@3?o);OZhY#0qKio!4Nh=tB)! z>Al_yG01T)%L6pHN=2#+lX&k8SURe-A1X+}q5OB^EYu^sIuX#(Gwr0M!VpKt~4+Q_419oYz8XRQn6Z zBcO~&p;dV{3bOW={SZaqbZAlR&=R&oqfZDNr+VHApd~-6>elGkn06uL$CIk55=<9m zF#j0qmt+o+zA3)g1Ww7&=hy<0*$T3|29&S?d;z=|L3*yMg_Z>S@%eNJ3BNp#ue-># zdKoUR4P6GQp^{xJp7Zgxr1(-O{B@6m{iP;2{kzaWq*A;~Cl)3L3;bd@7(H{bmqq*x ztjGhibWGZfDo~JHRc(N}=8X%LQ7b3r&h1#?PmJmNKdpe!OoV%*=>zcfbYikF!#4R# zC^Pt>V6>0ngxGOGeL#!vcrW-re`u~gfUolFvunZY_2Oa-F{*vn5rLEE_rcMZPqPauS%ViD23rh0w0U z$s*^4tso{8fX*%$s<;9pv2d_4Romf-lAUm9_YtUXJ_pB2n;|K$i1h*Q#4Zh3Nx+mA z(FSTE$#w+twrEHWO{eI6PjJGPuXJH1v>_Sq*#YCzM|lfV2(C=#U=B!tgP%PG-@bJf zQjng@(*q#4AHp0R1b1!?%QsZ&@k5XK25;VH6lI6Ev;G4XCj^^s<4*8^xgeioKHWD4 z#xy*vtSJ!qrXKE9G=hW^Adw;wt0<}!&a?sSEdgGDtlN)$%#-x3e;-S|5TvE0N(+|d z55JfWgXdb&^+x9Z$aENndVOGXV9jSYayUGrQ>+stSrbb!v9va~+!7RmKiqDxoQEHA9PHek-*@k4UpvklTK?!KU45~0{av5;IlpuK+4#Wq058Y}tXvbV@TamI zpESW-;HTgya8V#0zA+F8h`sefn_&$KY`m^DfTfqMs%r0eB?iJSUhJUU+@xc2K=K@~ z(O5u;M9u~v0(g(*$y(;kEtz|*7@$t^^SvnFQOMd=nAZ+w%CqS=BPRr*DD64%0lvi5 zw_8ZPyu4J5fM6~qKR+)40dW3Ua$RTu2e#r5KRVL_>50f{)g~wpkcG)13O^@Bq6fwV zV##rnM2tG~u^}~p3vntgzDY0!P?H^I!U8jp#{#CX`s!ERKaAT4Frh7|gSdJSQUq{^ z>^?dWO$>AZA)GgFE)^9Oi3&3sjqXT42lt?624OzmEUTT-Ka6-;rDASFmP`NuKUE)a z?tHTWHO1=g?h&Y@00JTdLIW^jW0zhi6haShnDk&WV4~CM5K}TR0aMEY;!M@4?&Oyh zd>yxT09;DqLAafGfuJ#?F!m)87f4TzcFAEk@ZT_otlps_fKX^t zEFRlA5FG&FXz^EGE8-^=1t2|%;!4a?wWf6p;mtL%V+17~XaO@YaHlf^fPvs%Zq1k&7>rp!nz=KR zIY1_eHL_qo#eAEGotFfmlkZJ-Y;Rl)KwAc1R)na*XyV}@9(I}$K}%bJ`@krfBS^*! zo61_#M$XU&H0|_PhMge+QMqsym?@b*=>m5o6F?zrV%4U%m&8ll61cS_-cCrSX)k{q(vVGe;{K%5C-WSqO2j0RYCb)Q{&kTf1{A4|+c=twJF zdN2jmnM^)CfA*$8+v(pdddq%_7j8~ykBAXa@weGiI$-!1&!#gu-=a&?ZWja#NR_X~a}Bg(-~F_#d?h{m@B#52 zQ26os2gTy5Ved)F7~B`=;{+yin@O$pj>-V}r;)vwBJ1je31J!-W@U@(NS8@z+)Y>B ztu`6F&13-9Yt0(n-2Mp7_lC(cJsVC1cEk_Y=v~i4)D?BozN1gm8z*0-x?j9U8y;9m zb^G5lJ)6a(WrL$LW0(op?JZNIE`7ycWT|Zl($MKA$@tG9a&Bs(_-|6R#8DMY6i#;O z+t)zMpywN}keem0v$V0}+OlV@_cU80!$Fh;Hy5mJwbGtBSKy! zZb9E&0wPC=%_;Wi48R04q>(;GY46*G_og~6ZPjCRodA+F~R-%suSbM&hR>*-SWc{=jWujwauKSdr5-El<_y1MftNWy3fl6D zRw^kjrXSaZN#`PH{Y_3PRxSyXz~m+B0W>)B^YUn4!xg~@HxrEAWBrYNZDAx)IDydY zrbiMF_b;dx3_w|q0ZKGVzy$mxh0AVbT!~PZ-){tip%G`KjP$chg5pk5V>QjL-nw z57R(ok{AFQIsjWBQ7{4HGttH;=*ESG2wUuJhfIIqdV;?C_y&6BfdlmT3*V=**$e1r z+jgfoM&HmN`QoAkKR%^2vg>7^al`T=iZjRKJuz~?ahA*`WkwDSp1nZ&@;4NFtIhu* z-`6`tn(idk#h1ouGGDHZU!>mH9zh_O0ODbW==))ZwC%RN?+5)+ztk4#Sd1pmP|LbL^HL7G&e=+IdrWec2s$y?~HCMY#f`$U0V+O z0-gFJ9klDEZ_>es{z{woe2Knx+uiimKVPG!d+RtHna$5N7q*eQgr5_S(fU(W6q_-h z+6t<~*HvwpYEQ0o*ilsS5gi0q>&K^jFJrF2*p;naXEZrqO z+sElMdOL7JB^n~l)z50)MVc>{)OD-av9Xu}K{gjA+@q4V;Z}0Y^3lz1b1L!jU@jbVo;rm`12tP#}7*X0JQW#9T~xd8jlTAei7~Q<2^cfQ^`HVQFUqQzoPVA3vJ4 z)a6m%ce$MjJJvu5OU0o%j_dcfRE7_`-flmsSGe3N<$k_g_yq^&{qd%=!rtAR?x;|* zUZ@-ES*IG`FD@%BZNV=p!~OkY9vsBOL@>yfDb#@pkoFCO2f(8WrRcG`aizlJQ-3NZ z@hcc5>G?g`$CX7*m9~4f@K!^}Aayg$#UmbyosU$wt6J+O>uZ=94>B{J35CN#8(o}tI1$G>fhuWzXEGV`G32zWWqC6yoV0b5ikjG|slij_$J>R+a+}z#l zhJ*lNTJCUXm(AVB|DXSL&iTIoUix?G_&+}`3WP`mg||Apf7XBt_5Frk7QPIRd_Pd# zVLox9Cig=AhF*opNRm{5f(90V$_~54x;`2W|8p*|XYcp(Y&ILm3_xh$3orpvw%_VC z#xW*CD0g{v844}T$?iYxqO<_)6tj_fv`Z^T&8~Z0u9jk9?ig_>L9kkr)U|%p^qA0y z>&~a}J+=VBF@qUnG=UWuSvVpVA*PH?WC{WSnK#N%@xmHLC@rloUx~)d4O3Ul2K60X zE;})DK?;i$n3{@!1>Z#g69_RrHP&le0Z#GfR&-EM1%~jVmkbW< zOk@85f@4t>Cn(v>N~B(M0k7A?fl)9^ELmgZz@D$KKdqS&1cW6B%V7Q+8NdnzB@h59 z0gh6#Lb$mj20#&dR+pfW_KE@^FoG0zXYs=Ua!g22Fa+XP!IvElJ71&_BOMV3zE!${ z$t24`jH((4qq6};pu8x8AeN-Dy@lX}B`5?iM1FzrgmOgBEto7^1B^sAYV#sL3kpPt za5^2_{38ird_SVLPCKSwIhz1n3!U z-XJ`JAe7U=Z2*E)n_=lA`H7lZ&eRs%a|BnVw~?VFJ+1F`bODcUEsJw@*k*@Gi!FDcM+NRE7SNK{9W4I~CO?oWf?Rv9i=l4M1<-^K`B{uK9$}_{yaQ~- zf*qglBLzZWfhrYv17QiB3D4b(SqfZ46iZMx5Nzk#61uESRACSn{vWMn1@J$x0uGI3 z0Swqe0ho>jxziu9V0MupOIQ=Whq;P$!4yz;@>B?UzPYKHm%4fqd8{7{1_&%fTqo-K zNTKydz{15;)7_G zs&9VL6|^aUnEtGXH3e|wxFwub(o>vp%oqUSnW<^Qu`NE)3;VVMdqV#hh-7HMX0~?hjXp~V1^;Q*2+}H?c)cuxLcwMkH#L+l%W5? zP#&JUNAHO#xBy7Q74QfP&|41T3IeW93AyJ~0Ze$T=G-SbmY}nVh}tMn^vz{q#!{<- zH!#;O?b4S)DM*9vQJXsa4MJ-!q>BUkRUwqtm~8143u`?mF@Sv#W^KihzKkCY$gI=h z?6MDwk=2`PWWbE}8UFOUsdec54zDAaiXOMo(55|!1!3)Gg|ETnMeA8S27@83eNb27 z@(`vWcV3tRB7v8H%XbF6_WaD%VKDS~W6}DgCkimA!fQ+0g6AWG4wNB;*9RpzVYoKh zFkeJ%-(?SJe{aiuIxK1nYtUPT%;I7o$| zUtt$aCf9n00}o)b_S4Jh*OukPL^GQYcOFe~m8?F`j}i5ZUIb%*SGq zQMn%dV%YIIvxdidlm&!VAw2vo)?sYi5dMGnu9$&OAI!uAhX>fJ=JOE`j$6e~KKi%0 zGz-8P48&1f?Z1`JP=zn^BvM(c8j#hoFQY_a;jy^8)(~|)0684~71`C@JNR9S+dG2~ zkhOFoS`|^k8pSGt?8StymFM2VFDaNjHtfAfXI^g;DH|g;g>T5BI)pUoTmsJn%nMD4 z{m7nolR}oLUrm>7&V-+<0)u~(oPu(?FAxk_oivM3`!&7GB)=|XqGK(aCQ>R6> zi0=MdT-vgjK85KsLvVrGl+SwsbI!b{lF2w>e@8-|BWlV1O zUtt}>{38#sn#5xR3xWxcy14MG4uU6rMI+Em|gk>4QLlb&ijPy9^OUY>?`!i>|9B>3DuEoXKXwdNNlW%U?p~q`$)|+qtK)U zLS%yRm-(qEtCfws%po*T8mKj#dKafCz@IpX`#XXjHf*xK2zflW|o%#8WB(m?$Ci{WQ$$sQ&GPP~)FrQDZyPpm3K=w;6XJcR4klBIordMS zrr-{kE8w(Q1#rU4Pq+{)1k3SoPvJU7+)@GsoK-2U(aZ401%Kf0MQwmvj!b`1sTgad zI4UefmfPa&sj1p#`E>ZRBY<@*>$z{7w-nBU~t(X%R~apJ~^^ANPIg zq1sZG=TZkVmtpTo&L;oK0C|?JC;#^STuDvL9U!1x-3Eu?07_y0kH5oh@EXjY-vo>c zZ-uYYAj2YZ{doqx|HQ|XXiKE=Ge?ojc>APkVvveufcJE=nW>!(LKQzcQnT?o}v&Llh-3BHE=yf1nv@BUc zu1Pz%`GUDBZkGUI1r0{e4#8tpotM)0Pov8Q7lf6_c$A8l(z-WF!%Aa4s~m9$=@Gk3 zXHA={sgNiC<1jibG^cO627@xWy=rWUF!)C~U$Ae}DMz z@6a#D&tWw|B7L~#4O;f}R@zy$fo9$`iyY4~bAP}`iNi)})nS(Td&;sOV6HMnBUVKg zNj*|PoT;t6cE5>IMr2abHA7i2OCht6g8yH`ROTk(*zl#ZPRaDKC$v;W;CzXxMdB z@XLRcR!per%?XDMm@xf;*VT>H`hdllG)Wm(_EIMT_Td zxj;i>CMA#PubMF^a5g^Lp`d}>F9+8vqA$$2(vSYJ&cmrn_Lg8hBl1ip8Wke^Kjzx{#S`)a7Qszrt^4I#Ok?HDD0cls`82N8$N(1b^Kp6?M6QV z2nW+PyZ14dw6%AyV08riJ=Weqw*V3?2b-z2#!bOMkYt=}#u8*_VEs0h(;ehpww9U? zx~YEWY4W$q(=QOkmm19*@+Kf7!np9SIhz=|0| zkb-P_D%YJR`)`!i+xwnO2!S_j%1UMhQaa}jgC#E4Go`HGPHB(VbsBe=5|E#`H6R#@ z(MJ?Vs)V3|Wg;d;@O@Dv8L@ZBTu91%x=Zn`TM8KTLh~6vG~f@CBhN{x6Nl?4wpQ}4 zT1P>btIOX7YhX%1#wmgDD2LHr16e^a>F^>+y({3{j0qn-cU~YeEd431G+61XM0k*= zlO{_;N5J9iwlkt!*TlUjI}I<6{#%nxt4nlh51|EytEuPFIdRZUy(W0MyBi(GGwHN<$d>; z8z}Miy}eG@JSzos^*H!Pr_*9HHFD~_#TZul#@$_g(FlL(xO5QxR{-{|1-h=PO#kQH zcaod;yGfdc^g(H9paL$fHY`D&A`UKL0n#vnh&n@Y#x)jDf(kg}*mhuaMnQ)RxsNmZ+vn_a_c{06o1{%zO0!qay04Ra&)NU` z-~ayp>A&eQJ*LMQJdEw%nm|22)5aO?kr=zbL-Kowvk_VjNqyT<*icwl*fP7m?&1&I z9H?iw0tngPK!gbf0ICHNFUTfWRFtD8A;h%A%xMj98V#s+fzl!MajLDUu4>=)m)*)x@D8(B8fK)@YywR@VA@96A($1<_iYUF^PuJ6g7XMUmT0Y8kp3#VGRKrH$i+&JX&URDu?&E&v%Ia6$69~R zCKa6t^Enq9-2wy?z!X5O3!o3Z#45q-PiD=k zwVW^4$D*9u3Dai8=&9G)d6aMYsH~{4oQLa;=}u+7%=BkW75|OvE=v8&teZSm6ZPnNL0k4C6$6 z4D23*rl_l{NQ!Zbh- zfiX}Mfu63swY%FLVzK;quvVLOM^iJqbAb2&tP@&5>8DaN2UX;-f zl@9=^gKCGgiB_z-nve6atGc?%RGETN;AIOlU`Hdvgu|Tl_V#hcK<^n@Yj9^zm&O(* zaTll40)nBSVMNpcZPM4r3+H&mAa4|uv8{A;B-PwEBhJG{KZm__$S__zRd`pY0|P9A z{naaxi!FQOAc>-%ZaVurmh_2Mw@!_RVJ?P8M%FM!u)tR`=IPN$%ZDj}7D(E7g_{Cg zqaDm_MrFl^eywE6Zcs=ayke+tSqor`8?Xm40HG&r{Y31qY=iBcE8#H46Nv`K1f~Qs zGt|Shj+s-C5i#WVWTy_MLianA5MzwSqQb)VX$CkBCJ?>=)VhwonJ~jFpza~uONXQC zG+=*)^Ony3G6yWoRCOCW8e#hpvI+D)Io20oG!|{q*K0x>;CcmPV(&OFLXAaDX2E;x zv5BesC#D%7l>ziZSz~|*0!bXE07UQV>gJJxJamvwN`h~xD*qqE-mt$&?c&i1O-xSE z1hVu7o`8W8**y>)-Z?E+tOXMulUTu+EG!>nlrNHReWo0xok zi8Yl0fVv0*gJ6gZ@CX7MNuQ>|f`TV>g7z=f^8`8yit)OEnV8ksRw6(NO>i;hSBTx% zAheG7sBs`-Y&5{*SO|_z0~*6LS@O1e#z>+Wvv|$!>jSSDrknwe_4MSiNeb)2qj4he zrpksi*->P~Ish{lYuO|&cj^Y$eXM5{lfLCh$_Nc$Cyaf-!NZnaAm61+@=RplyF^aG z={IG@V81shK#!*-0~|ftwSh@SBM-UIARg*m07%7S7UxZXR3S_g-neKgFT)t&JtrdC zaaoN%>|E1i1+vCMs48U$Rblg2PU`@m_0No4@$oNXH#9&P~fpkgsOP`y|{ zNkT5`Z4*HJvFtD31g9k|&K?{zEbM@&LgF#GkTzKxpo|N4U!V>+5jyMbD>?q14EGjj zyMo-ko71+8w+}FxoZ+-bBz8u?=gh2qICe&$=z{cR-DX)e>Pw{xkJBL?UiP~O~nG-&PEOA28WFi3MHD}9$ zNu4;&l}7_h4mvV`$PnuIC&QT-;*>LiG688{#-whIi+S*NNKoDq$U)kK34r4zFAGn` zkX5hK1PhIU&w%RbB=I03@A*zvC}V*Tiub&shsf9du;Vp2f#h14z*yFnE1jkS1ba#u zz(Ce%5I1U{Y0Ty5%xHb{5gP;QR*xm6|dgpWI3e#U0?+`%i7DrR6ViM~YP7E^{ zNbr-Y0i4j)#6k%e0gq5?vd4=X!cC$>l*J2X1Ps1bJ!uy#OKK*m%QSr3Zy7~|3T&v%n6eq_f3P@6ut~ISF73F-F?a#z=`+*`If7A7M6)7 zGA4XCKpTR=w9=kw$elC9x$w5pLm|_EG=mxCYF`iEyZd^J2e2*tZm^HwGGJ zK&3l*&$44CY%SDsVAq`#ExL+E7W~@c#cTeVux`mibpr&XwCyA$SK8ytI5l=01C z^qQnYC?|wOatnlaN)#PVc&S74Qb38*Jazb_M+mAtlV*?4VdL$@72Yjkajw=}&ei31 z;$uY1Z_*{d`07+`P=2$n8oRCPIUVr@q0^~+7st{WvD}}`Fo3#_6^TzaI6A(t9smL3CtulEJ+>6r6Ltj&6?%CPjbjpZ`xf zgE7+W7|7;`_WVyV?$P1cwr4AyH|HZM?{lzM&CE20nN}#65(YpYG}u!oH6@||KDmb7 zM@rVqf!#7^y#l`vp0J&@pBJrLLD94BNx43=w$HdGL?pstO&5k?1&{VTL)xaVk@xl_ zu}6PqG{61izmaRs&Ai1y^hw>B?I`~Uoik&RPEimZ44^1UMo{&Z2QgPRy;ks`NlcRg zlEp~0^zOy9?guRt%nI`NmwtUQ$9#QZ<;E0dm=zzUe9eq>CbyFqvsJupayo*_i4|en z(zR1_8lK+;$0Y3WFv;j;2iTYAn+zcLszl>Ix8VcW6H#W%7_r{)xaweec|G-2i!9#_?@5A>N|f*YhHSc z+V-~67TXl`_f0?BofwTu})cR~yx zL&s;zx(Uyk_Smo+hRJX`+%PX?yCVT;Fx62^-;j zIdy`uXn<&xuTQ9Nt>Qfa`2Xb9Q1)%9$*3)dhz|6tW(QU#+4Jn^KAPSBRW=`4 zR8cuQh4>ufUt)|d*Ias6y^i`v7@_ZFgkIfBzgW*0WBp_1Z(V-T)wE*S1CHhbOi-AJ zQjM)S0csn|%1V{vWq{8z23YHqRg>+jU;tjhtI)Xp1;>2)Q@Wi611Oj3p`f2Vl)&rh^+{si$iCG2-5`X zTYp0zo41taRL-N^k$0%};Jp;c$*25^St-P~GsfzTJE`LAZ&O84Wm^9LM-7co5yfmF zdmqs%6i{rMqSjKRlA4AGb!xg@P0{ zNodeZNf>>b60+oIboYN;N6S97oId~YFL6!K{OiA`*LQ8C->uj{*F1bN-7)`tdg!Ai z6eujEKv`9i2_&ts{E~Co^XK{Vy`vwa_eb?!lk;7JKkRe#RZye)cvqdzzIQEKVUKKA7Nw zvM3e!Q|&iQtLWT;T~l?(Rx};ss+#JO6CZ?WY-NypjB=33U_ANCG5> zu+3YPS5L+0yf>L2$p6xd8u4L#EGUwW8$OKO<7FfkxA1FEY3 z%l@>mv|J1$9#->E~4J}?Yuif zI!2PUg_(>pBpQ1eP>71>@I_>42LcHkPN4~kM&G1hqLujwsU+yHyw{ZWqk#$ zA#aLgWD_5L@_t&sWu0Tf$iX8W599&4FLtg6M&L%Rd@ycPBTj7tYOEC_;86|&? z$NV6*? z9J$Ok%3BoXLn(Q*^c#zL)(}*?>(MV$+n!b`FRY;d{NB2>38a~E7CLIF2_zO$hMB{7 zYU6X-v}M2aiy6=icP^FIq_Zd_Uq+jw&7@ z`5>U|6vElmlV`c(l6IbBVOrwxKdz$(*RSEPpSbHesw$j8!EIMjP0&llwP!Oq@TbWb zCZE3+xt-#QFD=36GvC5TnG;Ax<1pT6iA>VIqd%8AqX8Zv+`4KBJ$lD)siL6L zNFfi>^*{U+t-ST8^ppSn5$);t3%z%99g7}3lskI?DM-aTtRu+G@Os}en(H1Sw_m?e9-WOu=^n0U*cG0zo%g`)}OA-72NzL(-Yu+qUYpR%L7@+M(zs2o8h}gv@ z3n*YB3IODb>=c%klvt#F-Jv{+BwX~$Tfd~2TJ;@ZtL}J#f`ME!YnbKBp(nrbvSZee z)TB|q@Zewbng-uF4Hk1?Y6Ya^Uqzc?7lrEXwJ@SRfh32GBBW#{t)aTQ>g`hk6j{5( zOQj23G_bpq`reIl0%3c#D%on6olrie))&sEz>-BYxEZr_(78xuN0_>I`*c&n4o{Ys z{6#_XRu{AHbLj`0#Q4!}ig$LC8XKbc8}E|R`e*ViJ)dtSgN@{fqJWQK69|Gsve4QT zz5t>GOgP6m8qw&n?Jh>2JR?Cwbjjjd=&}o%IDvolhab?Eo$Kj_rGGJZ^ z#oMIk_;cvR@4UfVCF5K(_0Mkajq1m#!jI0sy_B>2yZpN8SUAOn?=4)ydq;^Lal#*g zHo|L2JBTmtNwi;P_me-RPN4P*S)tgu8XiYj=Fg=lkmDfn%uXl5{iGh-K^||1anout zN=O$u2!|3|$++~`9ZSOmaINCvW@hB2&D_NFUhyXNFnf(j1U)iE0Z1#ym;;C86VGW3 zCg_=W<0%=NA#&sx|35!FpA!{Y1?MnFOzme;z@PUq6XPN8l}pJN%vbj8*_#Ik z2}LrzNh7wmp`okcO@m3`DCTNQS_Pcj9=o!pyM>Ogj(&#dtNYu=A zW%wDLQUXbUW`g+m3=9BMqxNp&A*xg622zgS6^$juGBLdT{QSHzOU$Q?0lId^IMLC? zy5J)-DZ9RmyrqSb3QiuegPlAa10!I5TwUFym6lL~5$o{wej4r_A%CHd0Czx$zbY=P zr>vSHNpm=R^f4CRMgG`IN(WQr7cU?+Yk`C6*CUMBz5^6}XD6wNQHnqNe|$Y%EU9A4 z7FrhehQrGd1&C(0&;mm8A+197%Gv_N!+D4t<}u&lyt05r6@y@+O*)8=^FefkjbJMH z8p5_m+jkP>`5o)kIaYBZ5oSm2@syEI83UkatS84q)z{1>e|a7!7`lgQ5zPo~bWdL` zDI#}O3Avdnj%e^9dYhhP-pl!+b$kbYU(A1C@3>*iaIuth+Qx|10HPsWgyi#f)r_!C+QMa^o`V5oX~&SIUD$n|E@t1! z`xV6EE#&hzDP`ZBbeOu+1_&|qK>jwyL=Ad)XM~N0M2qK&NnW2*cB{OLnfQ5airw*H;ni&B|A)h5olRS90uURR3?g8U_ zR$&?QxE6{${(Pn6wGZ$v+x|XSH zQ*!ryJGX~@N50Gc9T^siT;Dv!-rYH9)(C-lZ?vQCfbF+HZo zc*g$=z}}aD$5EVVe?7OP8EItQvM%4|FqSb80yY+5`4pKv-@P+gYw;lZ_#OIYMj);{@;_Y=aMMOY)`5)-}54=&iT9tEZ-?XEd@be5vey zMsxLa*LS_&TVGX;{vU0lZM2QH(Kgyf+h`l*0gk;^zjK$WPa!`dwt79-&2cOXe@AWI ze6bdS!{NYIuZcc-L~aZ{>H!8np&?O1r2GwK13u*QUTF=>-iYgi0_ z)%12XAC8s;j)r0m91d-RYW|wOTF7z6!nLR9Foi`%2a19Mj^D%`BZd|x|ZQo)J=;rZ-Ej%7- zY--}Q^I-L`!PNA%c+ zRxB1}276ErRPUk$8$uVmq9FA{27pHtZ=#S>Y*}Ykfbruhj%Y7>NI{^mEanYV;btY! zB_6Apf()rd8g=?86-O$My@?@2xNm?vSs5T?lxkyBYL6#CMMZ^tMzCm52)^;9zW#Cm*Y z@7@NkEZFo4tBi|MZhS|~+!5#0Gl%L?9^k;*qC_MNQZu+?vE4g+ZTSJ+7&{M^iiI*@ zX%{>~pz@9=5F&m{yaolt_BYz^hPA~o-7cf9Urge}b;6r4_rLf^R{-S@c>vW)U`e)< zU8JX4DFMdXy849-z*WO)!Q>Se7qPT&GhY@Kf^2DNF=pm8Ha2nPgVk%Vd5rKNwt92x z)3U|J)i`fhpBumQx=^$V9r*Q)*(g+-P&4{b$Onq8&Ob!lEW&&|Q z-E-MFKS`nhd{Tzy^21bESYUuSG^~>Y;0Kov&ybgw%i}uM7Z%<~?4JajBK94Ux2qS| z-lK&pTvO$d$OVju0@T&-}AF zS9FQ`ijhifj~E5m_~~a?!)=Q=E`u{>QJBl_hhWSuUw8w24)5c!r>BQ^KHRef5mQ}J zUbZ-a0@SkeRp9#w0a0Ov`I2pL`SSHaF=RzFZ!#qm1X%^HhcBU<>Em<%EEECM^Nb@w z0S-C{?CcCIWSz4JZWA3M7EO@$A@GZMj}TTcth@acn67gulPFwOY>c zg$baT))MF zOPUf6z9=>n1d0HXNu->zlKHe=vT46E)K-hmX>|o zy<#0cvB06&?hSwt2NppxF>oo({m1zb24pK1G_UNs%^X5X=hpX25SwV5V=MClS;v$D zFd_1|FFH|9srbT+`}T3_6|NmEE6dM63jqL;H#Rlv0L;Oa04yECfmrcM-xg)ad;{`# zVWIFo@B*3Ais!*#Xvo6nUW^G|T#xy?I$JpJSi=BD+=ZpV6(awJAjMLpqG1^oqG;y< zr~#M>fq=?I{tZtc2pA}XR;AP5p6c~PV|FOn8$f zPBc(5EV-?%op(3}U-I(uxX?8rX?eIn+Xx|00>pn2r}2F#LN>z%Oc55bINQdK@+8;O zF2{Pg+)EHli8uzU*3rS2WsC6^$+peWNwH&x2VnU((w`*&eFz7rrxP&~!sJV@R+PuZ zO?j7Za~g&W0AT@&&bwrS0;SdHuv&tk40H9ESRD>S`y4dFsuMs++SP9s; zx#s#mT#u{(I2V);LWUOrxdUM$Bmf+Wj021xQEb8a9qo&BaZAmwzr_<`4;kT^eHhwyP-9=B&f zb4!cL6iLNiir7XEyR9D$3NVr-VekNa;0M+OLxV*jw#qp);Dn zK;bs_V@=I1kFejZX6N@qSs+ZfeVMQUlP7N{@6d;x0R}^%-Z=CRPlJ%tKhO`xYDsHr z8}6lsUC)eB50Hd~lijt91JJQ45-PlbBQV$WS$!XZW?*q1vtGYf-}nJwPY55^q8D<+ zg#fJgi!>7lvJ809<2jGt%<4t_cQC{cH;&)*W_Sm5W7%r+~s>+nHlp0a)79#suPr#cT0h$_(&6e#__e4*Q;j65zMA0k3#0A}ay1p##~^=crJC#-=89Fak!* zw7~&I;jy221-MzJ5KEZ52G5QXt2*ZH+dXMohYK8F>g<@tr+4*hWYcggKb~7qc@>ag zGOd0a*Cp`6;pz!S^rBcCEN}1Vr~`_3IHF7+@@)u3cAKy$kVaauhTBjG#A>*5F`my0 zPE>2+NDT5A;itEUV*&66$v|UFhxFBx>M1{9V0fe{fpU-qp4Ax$)G({45cxg~9YT>) zE(jYIWbQCn6Grreumz2b??ZqrF*GdB?CQ;=4(MR~nUy)TvOM1k9|d#1N_{83G9(2M zX{D87N*=16DM9v8nFT=jEH`e4@R7oyybEz2FcD;VqPxFsD z4**x+*4|#1m6@q+m&Pm{saU4I51w$x;NyLIfo7@>W;_KpCdsFV-pFD&Aqt8X+uL zsYuW88lK~IycXyR4B0L7DO);_6Db{-I1wVmcmtG5WgxPDj2Q2pZlPW+WEMRJfO(-}Z1!!$;`*TNU=UiB~sLIjOtPpqGaNj~`$o*X|OsaHq z8a9z&25SfbV)l;YQu*I_4;TT@IUysZhoX5`mXH!2ekP{cv#4vFIuuFtYHq*yl2+Q9eMzI>4@{d!TBED|{Jc3r}Fx z`P?HgC6NKyV8Rb)OryY@06*l{lC_LAXBzivi6255-Hb=U9GX4A5tRkBwY9go-0lka z3<1Ujt&2`K11`AbuI?@#68u>Q?_Tjc@_GSQoeLMtw~1$-heCtTA+dLFh>7*w+f@9N zD+FAAB7i8;QA_YD4O@$hMV>#XW2l8kgaTk-17qtUrp>aTar5YG^B6q_bue_x5pRl? z@7{Jh+1uYw?V^?FY9i9C;}{^ut&fGI5P7+~Hn&8=4HjyG71x4z-}R-9oEsPzdjkc| z+{M4!H}z@lbAu2}@~mShD_6>Bt3Iq1fD=3Vs1`2VY6OjIwi?y!?w)Rr!T}D4QtV;t zT2JYpT#)kTWx~ZO{}$u+JPUD6;leWnQGTaprMVZz3#{Y@<9ZC>$I?I$L;VvUjLQN< z>X)dY)J!ZoA;>P78b=C!SSkR7PtvR!TsU7cMVbXGXini3@EkYmH!a3wPQ#FcC(Oe` zsZIMcqwHi#z$cHC{YRX|M?7WmC)Ne*=vbM0Co0Kj7MWeYNNM#}bJ!_>S@@#EdCaHS z(KnIvihQ5bwn+*+!~WSZWQ;gOhx;X)!*Bl09B>+ajqe$Nb4zHyeOi38d$T6bvefV!j#B2`_*fg|PX$`VObz<^==CSI96c0JCb(=IalbV&%E_ z=^F8sC(LD|D;oQ*sE)1)L!vWc2T3imf(YMr9Q1H}I0 zo=!;t#B~I^x(pyF&ykQn1AN6&=tDveTZMqCIJd34SJ|z}Rt{=bEV>vIPMHfoSix5fMiy_OS1Mk<$O_qqLPLBn&rJ=^%!~lPWg5 z&i3|>s}H#XB+5%=G%u7v06$R~lb)6yHwcopUQVJx-@H>1Xs}*F}e10hqSN<)ujR zWWZ%c+bH4~GNmh}WOAxyxYEj{Jl{@4z<{Jl%Irm=g(0v&c_|m;z6KMx>HS1r!u?LE zC0QjKi&%n2cuu%}b4m^40rt`X_!qAL?1!gY(Uv0pi?J?UqqTAPp-nD=V0k`0SYv=s zc!!?pZyJ9K0>%n?%T|YwB(DwzoFXeh==cXL1+L1+U|vFmhIQ8)RxNKqBa8+8gsLPn zV;W+Yvp$?b>%v~@R}Bcz{zWo`vIkfo3pY{vB4R#Q8isDI+?&j#CCal0{ak|7(VUuE z#a;O|S?F^99hq=roMlJG%%U(31~!Kh7O3_`BkaUf2eY)87svShG1W^D_PPNtZ3uZO ztPB1#HG@7^4Uky}}a zfFbDclshSM)>7>#yK6#xX$<(Ptv^j$7;TVa15<+CWQ2+ZObOaX zN&y^JuF?w7h-|>2e+9*xVj1;BZrkdVwk~#i%YFP_;r!BTE{IFOF(!aa0OvsNJs1%q z7L`nySc-1=KnAnDZvz>s9?`A3;eArXsBpn-F6@{rr{pYhWlx6NMBCxBR>a2c$Eco8vK zJ?C|zx0&aWY6etCmUiuXRvIo+-M`*Y7T@^u$>A-6>(L}D4yFX2=GV#H_BZ-_&M&ol z8HC=MbMwrY=db+yFI2>oL9}&tFO7}#Q@+YQ^b#hkI;I5U4t59_@`{#lyMrz2&_C&c zSYjo7;OUpG=?zMM*GHcBPq56r&76ho>f_wL1Z;yjry0K+4c;e30iHO=8yE1;?8}@? z9f~tX=%mth$sgx%(pS5#%rKvAJoz z-M|eMIyRVoFcn}(0}|TSE=tna%dNKjMhc}*OOB$4n0`^$PV&50O76dwSl^HiV)@v+ zv@T#mCG8&|<@g(7gpE!&eh>G%{t1foFb|PGi}Ej8LCt|i+S;@!RtapfC$RD3#Gc>L zm%5@U?_CRTr8&3Gr2k&=5`XSZi)A%`_|2ct9ak=(Ywx=(zAxE%*Ezk^r5Hw6b1>E< z5E4vj3|0^un4rN|`$%E7O>p)q3Kd@-C&+~KYl>b%?du@d+a(FXAcT)tkLlaVRsrQ} z2Y>lKe$M_spU;orhxZxR*ZYKzA}t%peSLsjUt3BxCWMj;-XnMZmuYLmr_|No6(@M@ zxlIcR)IoE)e@1sb`8De54bU&DAEN(!^B-5W;{)yQ(jJ)SV`GS^mQam3FS$r$-ID2 z8k{e*bER?MckMIpq_i8E;LnMYPdzLYzg6d;kH*|bTPMCuO|83VXVd5LO28E%`uEE3 zoJ`BEn?H`^?t_;azfn2ql;n7fd4U?Ymj;yKlSlGO zT56)vH1jAIVkqkUGJTmt|G`z4kh15Em@9~|6w46E3Oy^xC?lnN=b#`ed>)Il8L?Rc zKpslctVZC3q6oobk_n%$@Nm=}d(L}`oF`ln^PRRnqIc_TNGj7#1mM_DZESl^-2GWHXi;8kRZgtaXqs-p1) z?ATuM6nTTSWUK!n3pqZ@EuLWV1i~|Dq2Wy1O{EZ48&u49IU`**1k&Igx1>4_YIUOvxjkayu_92G> zOBxR@G!ermhk{|X{Mk&9_v@N1DL|OwUQ<0~dcSTu;F?GO4`q0~{DNM7|9Luh`h|4U1<%n_FPu+{ZJQ}A zD~EiAbIhFw|n)fdMPJghAE`8`)AxBx~|k{2qj46_SjKb2pJn_LGbzc`8?jE z{2uEm;XSOutf%uG2NVKWTa;>taq2ZmMiBb=ehPhX2L)fdfcl^DX~&`xOQz6FuZpk` zse4`f3=?p&EFrvSWkTzh**_8YchuI(d;Bq1AENmydZ?x;#M4{;k6XNIELGRtAD|yS zS*5?GD}uNO{WIwdmA`TKN3^|hE1g$)5mlf4JsSUF9X+_NjXJk&q=D9!sNm&rpg(um zgLGE*VmdW#YmD%Pa1p*><=B4-3t8fC)E(kIZCUv*755Z3@nL(HQ@(c+Ws&x6B z>*!}M-%8bA`93ZG#Tshze}jI!?Gy?!1?b(j*^(iatbo0T1vu~?Kp+bXi-t8CsIg-G2E8j=cuWz4o+%)=Hr2PsS$16h4r1lmQtE%> z5>B>=_xoNxlX@Qa@k0nXv}+~x|Mdsf=aEPxr95_WhgeG>(T*PNdPi22m-DS`aL;KD zh33yrr?M=Yt^|@7AkEct*X%2#Tc72t*fJP6%~B3#{qU9+9p;+O8ONVb*Zu2V{Joc!KSoQ}{K5E*x95LseAk@AyCeJQ)aJX%h7$Iv z$74?!mTT6<-b3Xh2GD~d;F@7@BHjZFKsn$Ypai%__TB;QQWDd>C_Z3QUb&V^^Cr^0S1r=?N~!QiCm$?R zNM90>eV>Jl%pP*vG$Tr{f1K=X+mUj`-PitU4|ALa!HeBg5fX5~4%%knLKlj2U}-cCFB0dk*uW8C`? zDz>8)tC7F7v5&U4M!4YYio%8Pqf_0qyM70imz8mMiVy%i08ofoP3YMAojbWgi1y67 zAcZ2a6maYjrHGxnqWGEJOwa9$%TR;>bK71bN82XL8xJrKfDiyL4A3X%{xSjaQ^OKw1WXCq9xOd@feUwEFpV<4_B1&P zPf9wnht5En2P@Y&{0Ke%aUU0o$F9=Fc@1%~A7BvKL6CJg(kg_9ox64smYNqEUVhRP zp?~a!GT9O+foAnZ87fWB3ne6PgaC6|U!;uwddrU3S#;-M@!oi^BfF3$IinfdP#2&| zG&iuktGlZX6|RW{49k}*Yf@=lOK+#%e>|D+BAnx9QK)f4%qU;(J%N!Tcd5B=Yh#u&nt!Jw=-!jtfGGIS1w5bfETa9lWm)sOS5G$TJ^N#0lq z(EZtMQmqT$vna4{h9()77(478+$E6NDwpxZY99trUxl4 zJ>60Y?d1!!aBPbA&29T>&Z9R_ps$CXzw;%UR5~Rl!^l$ys6;i=_mtKkO6o2!#vJJC zs)h@LC&- zQA)cU7siB(-jGTw1HE+ZuP)=|V+)r)LyML^OO=ym(qp$g8&v`wXwC@7?6jV=kaW;^ z!Ia=?mKrX?!VieCmeaovgN7Iz>0C?YO&4qA3KzOZ6S5GzPz3&{37_mv{cushy(kV0 z3cLXJP$&fOf;`n5!iAAQ@BnU!0Go^jJe%&I%!2$l34~nb)SG(+4;Q@(3mfUh{4)#R zeulQ~*-H0b_pl-GIXBIu^G?5z?!5Y*IKH7I(ocC2V^lO@A*qsj2Y0k#vm{KnLRdhq zJ(MM2teR)X^(k9w(Dya!%lx;5=W1acR&2l^3!)ZY5y*d^j-b?={fqrTyr`Li)lF6Z+cir^RN))_EFzQo2ZC&GUwn?S;K^n z*bhJnOBT4Ikts%)-SgPuVlG_d>+)a?yg{PD8m>59UHoqw_!TH3KODv}hHB_rfNv(xYajcx>*CpIqA1)J&y)Z_?hE7qE}8lrKAtvQ8|b z^a+LJ%FmY0p&g;-Hi|U2QgHodvVpN_<&wQw#IQCb=wN)^SxW>+J`Tr*Y97>aKNS1*xEpMT)u!RC(ooy<}5rBA;cxn7DNVbo!X$X7N$~oi3rq;F z>rd}e&$%C1mU*ckex34iHqrji6zXGnJE&NjL!)GEb{iLT%z+wyq;fNkBU|}c+Q07# zQhd`5YqtV}GI^AYps{*S4i{;A?`O`Ad8m$di`v0x{b0MTg4WX|bpI>!sb>2c`u)GZ zPV2U>rg@KlpI_UfH$S75kl7@2*8g@~hJb@Rc4?Lx4w236B+q3#;;wt`x*FB~`c0Hqyp@X1C^mT$ zCxtr(_<0p}PFvoXSmk1!-She~>YVfdHBp5T`w>>mU{H`p@DPOs1zfm^;=RcI5%=-D zhgms7z|Ke!mDT@*(gx}%uVgYg(=y_$KdKbk-%o5gjc$7KN4$#I%G%|FR}!hcNy#V{ zjw(w_OFlUy3ZSm^s)0r~wQXWnDlF8nR10gBEwyYcv(n#KUia>0)$2N6|3jtSY2)Yh zP0Xc?iG}3wd&ymrZ-kIg6SGu=fR_C%4D8c>kDY(Z^xsnn6A0Y9nFun2L3jXkXsUxX z4f~quqNi`8J?$+tt^8zq_?l;qB@<&T->Ry3TTdzNb7zouYNDqBVxIZm5Kia(12`>v#>PzfiG z_@YXekgZs{5R-u=B8!57*FsK_>ukcG4wSu83lR1y}2j&w`+IcU?u zdTyou6ALKwgktiPjwMG~EZD=%0<48L@1XGR1}-?4KO^CJYgY_VTYVo*`qo6sINl0K zI;Bt}hnWb&7Vl8QgtM8asGu_eLO+!b8-fBEThns{FoT~L-mFS2WK`*SF*xI8-FFs`atp|i4n zOZ5XMv+JHpIkr$73yDwztO2EL<@+k68yN~JiP(FK4oVhqz#-s+EVaLf#mBWQQBjrD zE%nSwQZl@xc%QetZuLEyzxl0<1w5><^SQIrSqRAISpdR-!oj4SyMOBGqfqTGZH^;; zi(!kNbKjfTHQS;hP2UK^c$-`DAibMBy7$DePE6jnbniyo|A zOcM(y>!XS``s&Y5qn}^;5Y0aRY`XB?In>qHMZ2n}P&V@h8OK#xhXlzps0`+HbzNwX zQNk19x^WL)ua^to3Y;8GCmd7FIK?XkS>?f1KvjXx*5YM{qSfzMe;=?ul-3A<1z9ar$z%8JN#;$%{oKzDDx zfI2)=c#aRtL4BbJ0lBPWo0;_pFsMc%G4Xy!z)7pyyj<9~J^K}YJ=4d}q8l%|kH7ZG zj+OM-t9R0`u3<`0G=;zR%KOjL#`?AN*ms|eap}dKOQ^K-FXYTGB6~qmd`Q6G+jp>I z8|^&qfw-YzIo>A#r^YzJAyI&B^WAE&$H7BDdZCxR%t}3_xf)QD&^g?HnC0qWmIbgo z-~w}$6_E4nk1enJ<7*wuTp5n4saH;B%H>TG0%B9k(C#McTmB)n&-gWEO*)smQL*Ns zIRpR)2rvl2uWNPFh7ON0B>emvw^ASC{5ee6f4G*?TweOm8E7u0!|6{A8t={E zlwp-c%mZ9==t97%Y&D{~sdW=e@%D$g`?s4cRgp3P{#ca9!5RdnWIUHB1T5V0c1E=p z0<s;`GK_Wq7sJ)7Ch zu@^hyOa8Z)|2r417!ggilBlF)>=zdTm|(ydViBqe_LCLH0F6hdW8E4ZJD8RQeTCQzJ$O>}ye2bv-kzCvdOzT5eEnEH12B_)9n_w7^1k!?abKmV3Bv#EIc z#YRkp1zC*+mK0FnJnc?RzH~L2-9&{u{nI&?&rwS zY`x-Y>$Hb?j2FU9rIJXVL#T|P&=-=}5P^@*nW$eL`z88Ue+-3uTDiAf%K%gs$2_hL z?72QC2naqQjV#m24z0f290qKTI1*@Jb1enetS7ZUY42cf(TCN_T7TS)RvgXgBs;d!Bxaog+v00@{Hv z01`+GFB%DCXV{{);BLw*oXxGIsi}zyneda11y~i5?hZug)vb}}G;$?cOK8`vU7liI z;$?!*4koQ6@@~D86srlZOnS8icMPR+7~iUpXAq5u0_=G@T`ivJPcqPx2$(rF!lSo6 zN(*m#GgjbnL%S{$jU2V^a61ET|FbM%YH82&lYPo~QgpJZbI&)aF;d1m5S=>MSU|Eu z(%-HR^F<&N)elb1iO?i}B&ABCf<5;#MY8Vez|;o^jrVk2g}Jb|>wbe0H6;Y)XYbY+J_!!MDMM@a5PqW)+D-QsE8cLelS?WTy=+ z5&F16H71neIJrQj@=P^hB@sZJ5Flm)L4X*3b{I3AqBVqlj#BR4#rMo)6jP=;CiHQ` zD8xRn*0(fGtzT{@LCD}SjiGTTL)wn44DK=&PRO)Uq6We)5dUPZ}B;7qXPEI+enZ!3s$iZBXg_@d_1Wn5(T)t0Z!)zm?K_-Z+W% z`>UwKeR^EHk3=HNnc&YJR#E71+CQ&W)XMLTBd0f=y0-OF|IUEX=`jO7Ha!K)X9AsA zLAJ3))bsWSls@}7!%|z8yvjX5DCnTBExi=l*ROpa3SqM#b|#dO?YIe~xIEOms)qXO z_pqJ{#gRbv{A_Kkf&H%H_HZERp7E90n+exCeO3MhZhgp}NW|7y7OdKTxIw>W=BEJmk}p3z*>VIS^Tmhyv8ibEqd? zT1+h;_ENapqA`t3Me`Wy{R*g4+Td2KHACS=sGZbG2DJhVTYvA_(39V+Vs3l0xu9tsLfs z8;zSNFZd^N3k<;XY^qbr?>;EY#f}&e^!ZPH%b2BB2_VShza#1a4677YD1@UZ6-+Qj zp1OaLSKW_w6?6dT&Q9ZbJx2F9n6%k-AlS-LzaqQ=K;T!tMD~(D9MpScim;Bw{mQ|> zpo|wVK?OX4RdGhLN+P_MX=|qR-X-MnWtC-NM zOmu8JtWkHtmXjAY+~!tKylNcvwy4yzt&1Yv!Q`%=0ZRKx$C1K1HCgeBYh*0Yw`W;+J+x_RFrr*Gn|xj@>ud6 zKbBd)FSb(HAbz?iv||^Awrm$$#+Vr#Thad2nK_Lk~fF70;um1_P?7)Vy*zJ)8Ox=fgq>nQRTSxwAy>=k!YP8;W!uH9CUmN|)apRF#lFJqstN@>=R)%?BD%-Ad@S*@9o}R)19>g2|?BdK@fszbVk`6GBb<{ zh=y@wQy&=z7aZS9zY;}b#AYHIZ5AG-kt`q#R2 zfG9M&7;p@iuV@(n%H#L~04yA1yUq)0lEqrENM^?-h6EIRV6Aqq*GIu%0G~b1z@VIe zb({=u`4Y@o;r}ERSmX(nt1=j%Tp2>omx@iLxX_E0!jj1pjgL>*wXiLZG60mvF*-K5 z{UU^31_S^BnpX%W5DcbyqbpQ93zMQe$|gX09J|_oiEiiTPY9jb<56tW82B5VY=c=) zuE!|@KzYp6=-spTJM3XCw6jHc;jlwbmGyRxxi{b1zQpJAJ?{7W4f+o%4@!^D1<>?@ z;>-J%2h0I1li@UAGN1*PXrAu?B9H69@L>6FmI0tVN*p_P?cU8OcPnUkP+!W-s>vm1 z9q4`s6Sf|v{ePH9B-RMwhI0=90D;Da=X{|=9Ta5e9|pctX=!?l=0*_sX|e^3Rvcsb z?v??dJc^9hx4p4Sr`O~J0(O8P+wuSbp~uiWkC-zJ73-}m;r!6Z1_F2Jobwo zNbmtzSpynkq3vDKiKr-TOIw$`TE4SKcFaj9U{CL!tfZ|VL~EJmIp!zd$jInACcqs# zcJ89Fv2jDNYilE1I133sHVCb(rN#5@Z@v8F8iWd?9Wh93Uf;6Rnd=q3jzndLj3x*a>~0kEN==$7G;kv3@9!9m_c zHizPje%I7g8|Q=9;+*h(Y(i_xfB<2J;6n3PFhQM|nB?DSY;52{j^Fv&BfuqV@fSeo z@hBn!0$}jIY&PIC^gt~z)}FMq_1>~5mI2^M0RU28BZX+Pn9P{SPMUQ>U%3$)uDZIa zlYzjjwjJBu-IIkD7Ft&m#FXjS8yf1l;DU-nupx|AJ6$;DurEQ`A)wLdFh5`XUbHhu zVh@-B2Nbq{e8!!Gk{KcPgWt;FBL?1wb5X2?V;Nmc_|GYeVc7&YQo{d3C*c=1v1}t_ z!VB#tY+z_TdG$xzcowxd5pQ07u^Tm+j$VAuJ(0HvWP_J_7u`4Ko$mVgPV8Y4LNu zE*S(3$1^z@qeY7ruw%>^$BNIPeXF&;2IYj}G62|*-aUJFvq{qGAiz;w zI6ABX_vTyMSFz7KK}EGm8|NAnlTdc16&lcpGCj5%f|eHn76A zCT?S8Wd()9HAZ+2vV}hlYOZtSGu(80Q$mPc|i%=jJ*nmIJR>wYcAvJx^I<#b> zgaJah&LPG$Dw$d0FOWEdeS@J$z6Hm7Ssrs30JgvV&d=E-TP>V#cAD5O0ftSm4QxWT z3r!^iHGiUO9&X{(65s&fki%K{o$MQ!#y}bEIVA#sQNjxV5T1CPPknt|-uE@{zvkNj z3&1s4ym(Q;Og{0uL?S;MP|N)f6g^J-J@^nr@zC(_h<+{o+9H^Ync&gsZ^O)yosBvm ztSOfr&tU-Ax^>$plT4U}149Y7eG!AU$F)8^4`vN`2qTY5BT1;j^W1V8( zaHtg%FnvT$TG~1l7651F8mlSL+&s@g!)k&gR;Pz;kHyNI2DG?r=Zm3%rdslS1CYoL zNE`>M2XV}BDmKrXSMWaKw>)B@13|gmc#Z(T$jB&)R=U`PiS+dDF$ps0u1#3U767bt z95ndtciu6UaD=d8a$}OgIVQq@5OP72#Ri9(&|DCvv{_5>(Z~R#2rz--woXO(bpQzK z`{36E$Am{P06VQJSb*>a;QXDy0>WxNCnw53m4SWBe)g z2@U{#2M!J~O&tNvg|K1b$byaJ7m=Cf1O~!$Edu~nX2t+Th0x=-aFzj7I3Vb;?6{=0 zF!?~a0SXwxQ6@XKpsB>)!Ya@OKwvc!{%KXmF%Vb>^G3YJ^}#VAE6`r>G~YlhHVI&* zB}*3D3Yf6rOQrl%8LFZh*C?Gy8|=!?c_{lC#x>?uW&$TV0En;`G@Nj5$smA%@R!iu zB0OeCU-P(kB*KLT2|aT1i|{X0S63McFg)I#OqgQbB2fsN$P17`2=C)HmWH>C^-TEx zb|$Y+j=IN<;{`?l&yKf&~h#Nu+?=~Kb+26d-h3%)NCTxo9%UTA2 z0)YYxh;dUOtN+%kJF@)T^N#2M{04YLl^iw*J&qZFhwp=J`ea`_Q^GG_A6ZzT!L?X> z+7K?y37Fx26AT=$s3owTw=9seCHw;egO26K+()ApHtnQ2u(mA1C%6+(guNg{ddC?) zp`n52&1>S$yq?}({u&b&2{4!-_^xI)Bn+X4)`xb%zD7s24mJV+pf$lJG@pRiNNE*G zEf6R4EDpG!*5>6N!XvzvK7$UHNu!Z}liaxrF!x2LL1Ng%nt5 zH%wyCRWJajrWL~BB{Ui6E{lG8D54Pw zFnWdETK1a_Qw?v)*T;_Oh+`8;LISh^0_k+hFauCmFf=s8naph1{P4R7|AJh+WIbN5 z$LktQbYDQpq15s?jsQTPl!d^BGInzXj0N6PY3kiQAFu-xb*46~gulXHH zPk>odtZkNleY(k}&E0o&`F%I$}_w4O1Bva@fl0)RfrZzrwSoo4^(S`%h~-OwhXHeo^i;T=bUj+5}i zq_ATM)1kv!lpq(iKq_uBncza!*w9c^>v6Etz$B{Mx&_UI?w;Q7uzju-=LA5(@#m@; z(}p8Zd`DHaq$&Xb;+Vjk@OwBjajiwt6Tk~V$^cQM>posrTU!>+G5{PA5ZDbt5EB#{ z3Lb4yqG}atVfRL(L%cH0Dfz`N{H2)~Gv^2sX~uXA1w(l>xX|EXQ0@2%I=q;WhMD=% z%}9Af_#-?bv5sIMMWidLWv}ke5PO)eh~?n$)PzJ13bjVop9-#FdAlt&pz7@R9nx0 z108nEfQ!ZBt-=ftZM@a#*(U(O zuC4%~KK47|Ls(x1hB5$@3IeuauUxw+GGgCC5b#{1`xztl4WU9-8k}1C!j?0BtO&0m z-PkGiEgWmAQv7d=6j zb%;#LU?{}FA{LJsf;SU`*FQLXksYm*7%pXw`up)1nzpV;V7W?n4kM#Q+(ATqS2P!93~? zaDa~k=OvPo1WORwc{Y3<_`6muFPkGV-FYkC7vPNFqP{}D_R{YF1b3B1whRC=2=F=q zJ7E-Fd@cD!HHum=(O~C`%} z;(5LWHa0LIxUnq8a~J?H_V*naB0c#9P=fO^a$pJDhz;npZxDh>Y*+av3%+n(@`^qe5dxt_1^ovo zAVF%ToP4tZ1X^^)e7{rJtHn($i+kz_PEG? z%e_`j1vP@E%N1{ILPlY)vi-DjKq9Ri*va`OG6RJ+cSF^A0aG%eI|DUMiZo-fc;~S` z09vB3NFFc`+#J6Q1eSoK8 z!*+~H%|xtDEYUE;>agOzgls0Slvw=EZP-o3=|w!>&bc-*Imy3+bBFH$-DF zkRTMWozYEJB&EPkh9Ee_)VYI?X&U4Q;CU&HCuE^a!2_?y+%*roH_wI_6IW1OVWZ=< zweCP`Z|>!jRcLdQ+l|alGL%+D6#N1_QHAf-cZ-&b>|nn-FS}0 zFr6G}6X-pUj=$_X!W-?DUb-1hF>jfPh*s9-L>F{2_8Wj?Wf68%7gSl4Eb}G5*f~w* zJpA3?pg_;vW-YwrPOjMj2zlpm;2~1uy~gYDcRxR)EWqm6voqJ+_yw#CNrVq25NRu? z93R5~zyu%b-fdu-IwHrmWlAk#;Lw~1g~_(GaDr*eOvSZ!Uo|!SF{ay@6)yYF&*Vh5 z0|Jq!AgTdnn_Vyj@jH>}Czd1U%}6UMrv_rFDEar^p9cUla{htElxnz&lFiqb3=osC z$%3Cd0pk=#Ihp8a0{|2BRjG8kg9&T9Sm72iaH}v2nk{55#20XL4m7Xg+T;n$ik=ibe%=LAPq}BR#U7 zFgG!ZnHj|Zz{w;kgTQeD0R4vt&SnDrINMqcTE-@_RU^w&7YOJ@69>ZNK%HoosL< zPM+~d9vHX*0P8g{8k#V`cM~QpgTPS$0Fy zo;Z7>cHD2>GG!AZcJe)xT=Ljdw7Z+pBhY6J#7JQ>|r=fTPOQWn*~nvw&*pp zL>KkafaqRp-g9W<+px|1L)m}rR7wzG~p2wb;1~vgg zg}yTr1Lxw_X)E712KS904n)%@{k`2nmfVXA4qh3LDW z@B#=z+paYq*NX4io`-cma0J>hh$CSpSvxApt;U4iU#P8nfonVGoPvr=!FODM(yIMI z|Anw(lFO-_vZKc?&s$~&qFpyMzZ>C4iYt;|W>Y)49c~g}+{C_T3IyP!tNiaXX2}bb z+XdJWSP}> zyx206Dy6xnXWviAxAoTCO;?WOY?7roOvxp;QF{LMlnpuiQ|zu$p7v*o1)ZJwA|*_y zhwcBZQgRwe4=GuU|T+0YG>5LHHwXwLg$q_Hc{NH*$TvH7+X<94pO~QmkWV z+fIpU1{Tx!h1>Gpw@ZG3@WP*fth-Wu3ILjkwv9%ij9tUbVnYC@UFIZDqOx3n+w1nm zm&m*O{@D}mDKLpl^_i5oU?ZCxk%9nVH+K*v%7upmMpBqoHXg_mdfqnPT}=U^TOB8Q zwxHuZIy^A2n_b9Oy9;k;`@?p(qOMcU5fUXbI`4V&5yw*`Nf)A zYe(Zn0t`Sw_B|j32e5!0T*y&YY6hH4e^FT|H$Sz4L80{qyqy=3GQ4%#)LxlkUqbS| z8!6jxiS7KcYo)IBN0>0yHbfy9KvML5l>03doF9x#^=JM399oow6Bw*H5tk@e9JK#B zW`A`!*1g|Ba;=z(X)lx04|V%`wxGPOpDX%bLLv%0)27#;@fHA3W4)BN0058qGfgfE zVP?zGD9*i5Qw*6w)6PT?<+f#i*-Y z9|8G0P9UkRg;2N;VqS`I(eLSV2v8{)71 zl%d5Gr|KLSKu)@JFzD35py#*-07kpoO0i2tCYs$4;XkYJgSrA3#DW{87V?ymi3i(U zm`LYpa79SRIeEmHdKhU|k8vO6Sc2VCff_NE8fviFRmt*OFb2to3$h z>~qACp`4Xk94Ng{lCsbe@AosH{m=`PeQY6{Ov6;Hyhrs_QK0Eu@0P_(%oVj!?M8>b2~ zd|d4?4|FhiTnB?=K9rbu%KiO^w`8&zg8`J~wrqxh3vY?la{aVYh3#VBc1ctC9+IU@S(QarIJPUa;j^Ra16bt=d#WY2y?71%$0EFk($#FO~KF+lj^0lYZ@G(bKq7>SE z1}VwmBk!zo`IB@t1BFGmQEI;X6k)rvSZfqU^cO@FKwhR>c)<&r8kXxVd=KhVml6%; z0M+*?8vy8I3}~Y{^y;}{BdINBR+N76OO$yd7eSc}pG0*F-a{c@g;}`C1^~o!QXD>0 z=y2dHdaLR>N~zuZAN|@@T=3hLy_>%L@eZmARnz9JowQ-&_YFX(3RThiwGYtp zMJtN#uL@w0o^&d*S?AON7}=i_L0~!jBgO0BhEyD3KOblA7J0H5NrJpHUvYY9zR$a$m z-+tds)V1SfYOZUcAAIoHw6t$xxBZaQ|Ya5ugh0v)Sr)<%p~{#>_S|c?LT58A z=if4b0OOkWYw5BJKFn?Z|Jd{}efMYI)(rWz-=}jrRMVrt8<0N}LD7E-w3P(&&=?#K9} zms5JrW@C1E8qXyEyKf`k8O0r$2gbAX+98GdC&>_Q^P?>M0u0RGn>X+iY8-gTSd29s znMXBjbEEZ1wFd$5yOa{5?Q}T}&~nYb|Kk&!5n%k_3lG!s1)7tuZ*V_tc>4R?88|jM zX4wDNTzVbdc%^&IZ(KuWqSa@T)W|k}k-#WSGGxf=^7r_%Whc*SyD~cu+~Z7W+eEPm zywYNk%0fWklsjOx@xchh+IB~DAU?g|D#~KRwcTnl6Z-6vUl8UMadLj^_A(8z;xp#` zo&PWS2{omhp37dCqUgosg!2^lp%kj+5cfws;@VVvbR&5e7YC&60~vbxV3sD5IU!b* zEPu2Muq*6&2G5xWKmu9DIC=eh>~{wnZlM0g8_1U(XRP31Qipy?qay>{oUE>$N0s5m zqFUdHF?f{(ov!?ne}>$?@C6^FpZ@+7&IESseOm(nb{H*n^Xbm(@0xNF5fX+Pvq`FB zo41I`%zZG774!8Wf2{%8%Da zM{QVznj_t?tK=8zd6Lu>Hf^=e{0e1ypENSyN}^tauaVMJSm3lpP*X(#RvvEH8w3k| z7DawQ)phpu4zX00wj9jRtA{e&mKPdZTM9il`&BOH_$NOSe+J>lce7atf0nury+dDn zm&RQr1KADa0W?yQ>a(eW*BV02>tA9qe%&f6D!8yi z=*4FP0|O$17Sv?@5eER|HKRY8%mQQ&f2p+H3JlYV{m;}u8f?m9=k{;42LN{X8JXPd z-)#RqW)fukx4QNzn9^Sv`%q5(Op)kH()L;wO4WADvh4N|jb&-`o)m36oHp!tF7%{5 zW^}1gbs^7?(CQXxQ7l?Mt37`I>3^W-wmnTtnwHaD*WJgRb=dEwtxwUTzyAS!?ccw^ ze|~cFDfEvwexF|RR8q@N?x01L9{Q)14YZQ>(Ac59ZGBA?P(VHGC7FVY7Y)R=_|4%jbas zJa|L_Kw<%HG7At)08Xk>5`%zj(E4J{d+sCHsK;UYn}!c^3ft8CNKHrzpl+ssdY;Q_H8cMo>^NTF~3d#brg;k zj}#6(c<2x{)YlgTi`~&Qy|^z$Zx5!q7FP^PPljgC=rmo{^ce`WlKVV5dXV}K@1(O& zUa4vSY>o|NI7Dc#dzkjeSarq~6be+a{bJ{-ePShr*=Nljru-E5_B+fj2z703t?9GT(NWW9;@*nSULXEPc=0Abf~{0K$D(smTru0056{ zi&G*^CT*`6PCEIy#0aF%@%%S0KS;lMNmE)EoO&7k)g_;$%3yV#Gw|)bTj=ip_#FS< zQzlP}^1X<-fR#|Lv~smuSN?4^Y?cmo*ElK0ptD za1piE0Qgk$R#uUJ{$f(WHcRjm*fIw0EM3gBGAc{`1?_iS*FvB13QehrX+U51QhWv= z!2M>wi!NJsa_vz80B)8J4h=D_&DZp^Y9G6Bl3mANJK8U==T=IZapJizQR>zEr*>k2 zF!`@|#FW^=lUg1(3$}0NJ)~HYSF)o$l>FVTrX4Gcaby$2)4$;Q3w8cV@?Y)TYXDw) zxlOiH%CB9nKPF<|fdf>>v`r)u;rjs?pp$Nx+5U|U4SD-{>u`#G`A(AJX-V+OYWLf} zj*cCmZ#;aRX@7Oq&2;Nk|3FI`mz%M(g1EGEV4EiIp5|9+yKJ8%&- zE3x%v8u2bP+3%R!PPToq&tL}NeQd%E2s1 zT6ka(5Pa?eX=>stC*z*NJitT*K-AUM<^ArD`&0B>Z;BEb3kx8b1!#|N_B>BN`mg^* zL4PG(_x>-_@&#@5@H5|}t-Euk+nPVUlUAO7g$Y0g7yzt){7&lW*UW+nW)fU@$|^du z^<3J0@E!W)A0OrK-_ZVP`sAfI8^0|YhGSdp=Y(NGL*<>iRFYB0iOo~$<0pV6=2`r3j@}cjM?=uf#Bm_j*N_;uzXk$TB98X<^DD z#XL9}XnPq5WDt;~~+Dwm=?DCZRC1r1BF?^M?zk&+?8-+2V~^5T=8+ z-rAp`(RkJn()TsT>AXdinprnA#Ms0z@4gH$z&@kVCP?xE~varj*;bs+KTb2=v(&$51Q2OB3V^Y8!!!CJ_$QeVZt70Zt4s)G$DQes9cZmDLFZNvQP`U_Xuj+e zl)+$daFBz9%oYF;cu!^vRxtR@?iB5g3ed3!1UuI78UukB-rPtZI`^Y=+M;tbdtIe` zXP~X{v$^NZ(t^q))d!2m-(|;JWBm72J^8W*04~Q+&HE_c^g&m)Jt)2&%F`O=u=i!V zAIE{mLgoV-?MK}NkeL7z(r(x%$VNlrh*$%#gtgdBQGjBdYh8yKqdheC*tG?L;E0+E zmfx%4#T0nYZR9`edQyXtlJ+S(?3`@$EeBHctDOmMqbn9iU6Vgfm(~wbOLH@yWT2#Q z2+B0y>J+Tg!&Y`|K>&oz7EJSmF^}JNr>Sp@Y@LO+X~CBCVC#20QbeFOn5EY06h#7= zqSvjE09Nh)H}c23NlB($-`vr<%V{WhZpn7PI1XX=11Q9Gb21}1=p_yQ&dytG22mokL?KhsG;0E3B5cT?i6CmBPSxke$yn1pY|mE>(ci+rt@kf-^~ zk_Fvv0`J+o*U@2zFrJ-_{^GtQ{nxIPNz*O~t8{7e1Wk-+S{z`22`Hii#hijpA;kW} zhdDsVY(XA}Cm2KN&CuW^*@q5QCt%2ZXK{6gme;14rZ2)SOLV-|cCXXGrG$Gjy5Mh! zs+X5!_uJX_!bBKnrq5_qEu}r$b>q!P$<%yQ9<$bbv}Dm>Y~YiO&Tr5>mT8Up1cP*} zk5u1ll+i&Ux%;`CQ}G%5{Qdf`>>)Lz3E9Yh-a_#$_60xwcU?*0>mHraV-+DkCkie* z>>!ZH9q+p5d4s7@-^s2BW!zI)CwK(j+Zg;PZu$8$TuGvzW6$M zYI6>{(I>w|6EA3a2%gAd3cmY#s<`Mj10-g0I0=;y-g@;ln&*F$)CmS^+2OmxY{yrLYBg^o?cdnO-I=?CrLB z3Y}c!@=Yh z@CzD^P1x>l%@yu?3WTz0TAVR8AGLzP>gn_woVIHWU4&BU)LLiUrFigTl<;3;Vhb=GN(mv_`4);rG6eX|L{g0qc=4Gq5&VH%7LW;^}Er@w9b8z?pY>H~jAJN9Y4 z2$n58h1UP=1FqVP0Srz`$DB%vr__mf#(d-YLZTazsbsrY3lMFpL8RkAW(} z4mV&hD@FPG!lLLcxR$Yl2z&)vhEvv5!6q`;j%05D-+}wcfAD@%6TSJD!m2Iv1g(fh zwLH&wTevn&`Pxf5#vMC%@(G)L>Tl@Oi*}MX=(GH7tMEG@(Vs}E6h*-;R(9u#~u8+ z(QDB9JsUrPoc|Ecq-b$!YRyNFnB<||7-@?qw6hz7Ey63r^k{%}&3Duc@JceBLCDW- zPehMF8(Q5`Cm%TYJqqo;(^i}3gxN;G^E9fOPn%t6)G@l564^8J&ck|~X$F=Y7}?hM z@U1lQ>SH|G5{&q%;haXQSyo5BaHyoeEi}6MwJN2;s+8K($G7y(UcM>_u4V7L{+a5Q zUNq$pVj;Gmbu*sbq#8T(LEXjXfqIA{iwmqRiTeYEC5qcDRfKyz7t$`z2Tg+c&i?JZ z&DdB>%XCDWvF5t@wD!9<8C`|0S#>S5{cqM7foM-QIZ9W3K~oUC)!2&jT(JPVUV}Wx z0nJBK?wgM)bPNrq8DK`VN*fGuu}Dc9xm>E;Zj3+wZCX_Suevt%6tR~J2zQ>qsgkyp zgiR8%Y%)zl+fJs`^1mtWlVFo>U|^7I-4)|ElW$}T9o*)pcvRs)fK3&T06mpV@A0)9 z8<~9@^z+Gz_p;-Yd{fjMP#VvT_q2(w_`T#gX(4$|Swa)aS{jHp8c~Spgb=N40sEPa zq#997<77UJJQPr3(u74u*50o<_tso? z6Ww^(&0NUudANhO7;_p%pFx+xFM|N@K$K2WT_#yDv?)bGI!-!i>0QSh0LC@hI zY;tv|9);>pk5G77glbT2Y3mG=sIx5K78}_t^!XY!`Q}baA3V(8PMm%RC6`{;r6aLG z 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 Ef?9xR5&!@I literal 0 HcmV?d00001 diff --git a/test/FakeStockQuoteTest.java b/test/FakeStockQuoteTest.java new file mode 100644 index 000000000..3dedb885c --- /dev/null +++ b/test/FakeStockQuoteTest.java @@ -0,0 +1,19 @@ +import org.junit.Test; +import utils.FakeStockQuote; + +import java.util.Random; + +import static org.fest.assertions.Assertions.assertThat; + +public class FakeStockQuoteTest { + + @Test + public void fakeStockPriceShouldBePlusOrMinusFivePercentOfTheOldPrice() { + FakeStockQuote stockQuote = new FakeStockQuote(); + Double origPrice = new Random().nextDouble(); + Double newPrice = stockQuote.newPrice(origPrice); + assertThat(newPrice).isGreaterThan(origPrice - (origPrice * 0.05)); + assertThat(newPrice).isLessThan(origPrice + (origPrice * 0.05)); + } + +} diff --git a/test/actors/ProbeWrapper.scala b/test/actors/ProbeWrapper.scala new file mode 100644 index 000000000..e8b48e93c --- /dev/null +++ b/test/actors/ProbeWrapper.scala @@ -0,0 +1,13 @@ +package actors + +import akka.actor.Actor +import akka.testkit.TestProbe + +/** + * A wrapper around a TestProbe that we can inject into actors. + */ +class ProbeWrapper(probe: TestProbe) extends Actor { + def receive = { + case x => probe.ref forward x + } +} diff --git a/test/actors/StockActorSpec.scala b/test/actors/StockActorSpec.scala new file mode 100644 index 000000000..9aa13b72e --- /dev/null +++ b/test/actors/StockActorSpec.scala @@ -0,0 +1,90 @@ +package actors + +import akka.actor._ +import akka.testkit._ + +import org.specs2.mutable._ +import org.specs2.time.NoTimeConversions + +import scala.concurrent.duration._ +import scala.collection.immutable.HashSet + +import utils.StockQuote + +class StockActorSpec extends TestkitExample with SpecificationLike with NoTimeConversions { + + /* + * Running tests in parallel (which would ordinarily be the default) will work only if no + * shared resources are used (e.g. top-level actors with the same name or the + * system.eventStream). + * + * It's usually safer to run the tests sequentially. + */ + sequential + + final class StockActorWithStockQuote(symbol: String, price: Double, watcher: ActorRef) extends StockActor(symbol) { + watchers = HashSet[ActorRef](watcher) + override lazy val stockQuote = new StockQuote { + def newPrice(lastPrice: java.lang.Double): java.lang.Double = price + } + } + + "A StockActor" should { + val symbol = "ABC" + + "notify watchers when a new stock is received" in { + // Create a stock actor with a stubbed out stockquote price and watcher + val probe = new TestProbe(system) + val price = 1234.0 + val stockActor = system.actorOf(Props(new StockActorWithStockQuote(symbol, price, probe.ref))) + + system.actorOf(Props(new ProbeWrapper(probe))) + + // Fire off the message... + stockActor ! FetchLatest + + // ... and ask the probe if it got the StockUpdate message. + val actualMessage = probe.receiveOne(500 millis) + val expectedMessage = StockUpdate(symbol, price) + actualMessage must ===(expectedMessage) + } + "add a watcher and send a StockHistory message to the user when receiving WatchStock message" in { + val probe = new TestProbe(system) + + // Create a standard StockActor. + val stockActor = system.actorOf(Props(new StockActor(symbol))) + + // create an actor which will test the UserActor + val userActor = system.actorOf(Props(new ProbeWrapper(probe))) + + // Fire off the message, setting the sender as the UserActor + // Simulates sending the message as if it was sent from the userActor + stockActor.tell(WatchStock(symbol), userActor) + + // the userActor will be added as a watcher and get a message with the stock history + val userActorMessage = probe.receiveOne(500.millis) + userActorMessage must beAnInstanceOf[StockHistory] + } + } + + "A StocksActor" should { + val symbol = "ABC" + + "a WatchStock message should send a StockHistory message to the user" in { + val probe = new TestProbe(system) + val stockHolderActor = system.actorOf(Props[StocksActor]) + + // create an actor which will test the UserActor + val userActor = system.actorOf(Props(new ProbeWrapper(probe))) + + // Fire off the message, setting the sender as the UserActor + // Simulates sending the message as if it was sent from the userActor + stockHolderActor.tell(WatchStock(symbol), userActor) + + // Should create a new stockActor as a child and send it the stock history + val stockHistory = probe.receiveOne(500 millis) + stockHistory must beAnInstanceOf[StockHistory] + } + + } +} diff --git a/test/actors/StubOut.scala b/test/actors/StubOut.scala new file mode 100644 index 000000000..717d80e3e --- /dev/null +++ b/test/actors/StubOut.scala @@ -0,0 +1,18 @@ +package actors + +import play.mvc.WebSocket +import com.fasterxml.jackson.databind.JsonNode + +/** + * A stub class that looks like WebSocket.Out to the rest of the system, and + * returns the actual results of the test to check against our expectations. + */ +class StubOut() extends WebSocket.Out[JsonNode]() { + var actual: JsonNode = null + + def write(node: JsonNode) { + actual = node + } + + def close() {} +} diff --git a/test/actors/TestkitExample.scala b/test/actors/TestkitExample.scala new file mode 100644 index 000000000..dd2fa22ce --- /dev/null +++ b/test/actors/TestkitExample.scala @@ -0,0 +1,30 @@ +package actors + +import akka.actor._ +import akka.testkit._ + +import org.specs2.specification.AfterExample + +/** + * This class provides any enclosed specs with an ActorSystem and an implicit sender. + * An ActorSystem can be an expensive thing to set up, so we define a single system + * that is used for all of the tests. + */ +abstract class TestkitExample extends TestKit(ActorSystem()) +with AfterExample +with ImplicitSender { + + /** + * Runs after the example completes. + */ + def after { + // Send a shutdown message to all actors. + system.shutdown() + + // Block the current thread until all the actors have received and processed + // shutdown messages. Using this method makes certain that all threads have been + // terminated, which is especially important when running large test suites (otherwise + // you may find yourself running out of threads unexpectedly) + system.awaitTermination() + } +} diff --git a/test/actors/UserActorSpec.scala b/test/actors/UserActorSpec.scala new file mode 100644 index 000000000..f52015737 --- /dev/null +++ b/test/actors/UserActorSpec.scala @@ -0,0 +1,65 @@ +package actors + +import akka.actor._ +import akka.testkit._ + +import org.specs2.mutable._ +import org.specs2.time.NoTimeConversions + +import scala.concurrent.duration._ + +import scala.collection.JavaConverters._ +import play.api.test.WithApplication +import org.specs2.matcher.JsonMatchers + +class UserActorSpec extends TestkitExample with SpecificationLike with JsonMatchers with NoTimeConversions { + + /* + * Running tests in parallel (which would ordinarily be the default) will work only if no + * shared resources are used (e.g. top-level actors with the same name or the + * system.eventStream). + * + * It's usually safer to run the tests sequentially. + */ + + sequential + + "UserActor" should { + + val symbol = "ABC" + val price = 123 + val history = List[java.lang.Double](0.1, 1.0).asJava + + "send a stock when receiving a StockUpdate message" in new WithApplication { + val out = new StubOut() + + val userActorRef = TestActorRef[UserActor](Props(new UserActor(out))) + val userActor = userActorRef.underlyingActor + + // send off the stock update... + userActor.receive(StockUpdate(symbol, price)) + + // ...and expect it to be a JSON node. + val node = out.actual.toString + node must /("type" -> "stockupdate") + node must /("symbol" -> symbol) + node must /("price" -> price) + } + + "send the stock history when receiving a StockHistory message" in new WithApplication { + val out = new StubOut() + + val userActorRef = TestActorRef[UserActor](Props(new UserActor(out))) + val userActor = userActorRef.underlyingActor + + // send off the stock update... + userActor.receive(StockHistory(symbol, history)) + + // ...and expect it to be a JSON node. + out.actual.get("type").asText must beEqualTo("stockhistory") + out.actual.get("symbol").asText must beEqualTo(symbol) + out.actual.get("history").get(0).asDouble must beEqualTo(history.get(0)) + } + } + +} diff --git a/tutorial/index.html b/tutorial/index.html new file mode 100644 index 000000000..d2e7e2641 --- /dev/null +++ b/tutorial/index.html @@ -0,0 +1,109 @@ + + + + Reactive Stocks - Activator Template + + +

+

The Reactive Stocks application has been created!

+ +

Explore the App

+ +

+ Once the application has been compiled and the server started, your application can be accessed at: http://localhost:9000
+ Check in Run to see the server status.
+
+ The first thing you will see are three stock charts which are being pushed values in real-time from the server. These values are simulated so the application always has interesting data flowing in real-time. Clicking on a stock chart will fetch recent news mentioning the stock symbol, use a service to do sentiment analysis on each news, and then display a buy, sell, or hold recommendation based on the aggregate sentiments. New stocks can be added to the list using the form in the header. +

+
+
+

Reactive Apps

+ +

+ As The Reactive Manifesto outlines, Reactive apps are Resilient, Interactive, Scalable, and Event-Driven. The Reactive Stocks application has all of these characteristics because it is built using the Typesafe Platform, including Play Framework for the web interface and Akka for managing concurrency, scalability and fault-tolerance. Both Java and Scala are for the back-end code since Play and Akka support both of those languages. Any piece written in Java could have been written in Scala, or vice-versa. The front-end uses HTML, LESS (compiled to CSS), and CoffeeScript (compiled to JavaScript). WebSockets are used to push data in real-time from the server to the client.
+
+ The Reactive Stocks application showcases four types of Reactive: Reactive Push, Reactive Requests, Reactive Composition, and Reactive UIs. Two types of Reactive which are not directly used in this application are Reactive Pull and 2-way Reactive. In a real stock feed application, the stock stream would be attached to an actual stock feed service using Reactive Pull. Those values would then be pushed to the client using Reactive Push. The combination of Reactive Push & Reactive Pull is 2-way Reactive. Play's internal network handling is 2-way Reactive. +

+
+
+

Reactive Push

+ +

+ This application uses a WebSocket to push data to the browser in real-time. To create a WebSocket connection in Play, first a route must be defined in the routes file. Here is the route which will be used to setup the WebSocket connection:
+

GET /ws controllers.Application.ws
+ The ws method in the Application.java controller handles the request and does the protocol upgrade to the WebSocket connection. This method create a new UserActor defined in UsersActor.java (tests). The UserActor stores the handle to the WebSocket connection.
+
+ Once the UserActor is created, the default stocks (defined in application.conf) are added to the user's list of watched stocks.
+
+ Each stock symbol has its own StockActor defined in StockActor.scala (tests). This actor holds the last 50 prices for the stock. Using a FetchHistory message the whole history can be retrieved. A FetchLatest message will generate a new price using the newPrice method in the FakeStockQuote.java file. Every StockActor sends itself a FetchLatest message every 75 milliseconds. Once a new price is generated it is added to the history and then a message is sent to each UserActor that is watching the stock. The UserActor then serializes the data as JSON and pushes it to the client using the WebSocket.
+
+ Underneath the covers, resources (threads) are only allocated to the Actors and WebSockets when they are needed. This is why Reactive Push is scalable with Play and Akka. +

+
+
+

Reactive UI - Real-time Chart

+ +

+ On the client-side a Reactive UI updates the stock charts every time a message is received. The index.scala.html file produces the web page at http://localhost:9000 and loads the JavaScript and CSS needed render the page and setup the UI.
+
+ The JavaScript for the page is compiled from the index.coffee file which is written in CoffeeScript (an elegant way to write JavaScript). Using jQuery, a page ready handler sets up the WebSocket connection and sets up functions which will be called when the server sends a message to the client through the WebSocket: +

$ ->
+  ws = new WebSocket $("body").data("ws-url")
+  ws.onmessage = (event) ->
+    message = JSON.parse event.data
+ The message is parsed and depending on whether the message contains the stock history or a stock update, a stock chart is either created or updated. The charts are created using the Flot JavaScript charting library. Using CoffeeScript, jQuery, and Flot makes it easy to build Reactive UI in the browser that can receive WebSocket push events and update the UI in real-time. +

+
+
+

Reactive Requests

+ +

+ When a web server gets a request, it allocates a thread to handle the request and produce a response. In a typical model the thread is allocated for the entire duration of the request and response, even if the web request is waiting for some other resource. A Reactive Request is a typical web request and response, but handled in an asynchronous and non-blocking way on the server. This means that when the thread for a web request is not actively being used, it can be released and reused for something else.
+ In the Reactive Stocks application the service which determines the stock sentiments is a Reactive Request. The route is defined in the routes file: +

GET /sentiment/:symbol controllers.StockSentiment.get(symbol)
+ A GET request to /sentiment/GOOG will call get("GOOG") on the StockSentiment.scala controller. That method begins with: +
def get(symbol: String): Action[AnyContent] = Action.async {
+ The async block indicates that the controller will return a Future[Result] which is a handle to something that will produce a Result in the future. The Future provides a way to do asynchronous handling but doesn't necessarily have to be non-blocking. Often times web requests need to talk to other systems (databases, web services, etc). If a thread can't be deallocated while waiting for those other systems to respond, then it is blocking.
+ In this case a request is made to Twitter and then for each tweet, another request is made to a sentiment service. All of these requests, including the request from the browser, are all handled as Reactive Requests so that the entire pipeline is Reactive (asynchronous and non-blocking). This is called Reactive Composition. +

+
+
+

Reactive Composition

+ +

+ Combining multiple Reactive Requests together is Reactive Composition. The StockSentiment controller does Reactive Composition since it receives a request, makes a request to Twitter for tweets about a stock, and then for each tweet it makes a request to a sentiment service. All of these requests are Reactive Requests. None use threads when they are waiting for a response. Scala's for comprehensions make it very easy and elegant to do Reactive Composition. The basic structure is: +

for {
+  tweets <- tweetsFuture
+  sentiments <- Future.sequence(futuresForTweetSentiment(tweets))
+} yield Ok(sentiments)
+ Because the web client library in Play, WS, is asynchronous and non-blocking, all of the requests needed to get a stock's sentiments are Reactive Requests. Combined together these Reactive Requests are Reactive Composition. +

+
+
+

Reactive UI - Sentiments

+ +

+ The client-side of Reactive Requests and Reactive Composition is no different than the non-Reactive model. The browser makes an Ajax request to the server and then calls a JavaScript function when it receives a response. In the Reactive Stocks application, when a stock chart is flipped over it makes the request for the stock's sentiments. That is done using jQuery's ajax method in the index.coffee file. When the request returns data the success handler updates the UI. +

+
+
+

Typesafe Console

+ +

+ The Typesafe Console visualizes the internals of Play Framework and Akka applications in real-time. To enable the Console you will need a free Typesafe.com account since the Console is licensed under the Typesafe Subscription Agreement which allows it to be used at development time for free. Production use requires a Typesafe Subscription. +

+ +

+ To enable the Console, in Run click Login to Typesafe.com and login. If you don't have an account, then sign up and then login inside Activator (click the person icon in the top-right to open the login form). Once logged in, click the Restart with Console button to start the selected application with Console support. There will then be a link to the Console UI. Open that link to enter the Typesafe Console. Learn more about the Typesafe Console. +

+ +
+
+

Further Learning

+ +

+ The Reactive Stocks example combines Reactive Push, Reactive Requests, Reactive Composition, and a Reactive UI to create a Resilient, Interactive, Scalable, and Event-Driven application. Check out the Hello Scala!, Hello Play Framework!, and Hello Akka! templates to learn more about those technologies. Go back to the Activator home page to create a new application. +

+
+ + From 7cb059223d735e0cdd38edb3d50aca4becf96f6b Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:04:02 +1300 Subject: [PATCH 02/83] Update to sbt 0.13.1 (for java 8) --- project/build.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.properties b/project/build.properties index 0974fce44..37b489cb6 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.0 +sbt.version=0.13.1 From bb568f4bf92359d912d7c6d2a692d520275de6fb Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:04:28 +1300 Subject: [PATCH 03/83] Require Java 8 when loading the build --- build.sbt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.sbt b/build.sbt index 6d21ca22c..944aaaa77 100644 --- a/build.sbt +++ b/build.sbt @@ -12,3 +12,11 @@ libraryDependencies ++= Seq( ) play.Project.playScalaSettings + +javacOptions ++= Seq("-source", "1.8") + +initialize := { + val _ = initialize.value + if (sys.props("java.specification.version") != "1.8") + sys.error("Java 8 is required for this project.") +} From e78767860aa8fdf69bb2c4b871b6e16589d0fdd1 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:04:44 +1300 Subject: [PATCH 04/83] Use java 8 lambdas for websocket callbacks --- app/controllers/Application.java | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/app/controllers/Application.java b/app/controllers/Application.java index 72a70e51a..7eaf56221 100644 --- a/app/controllers/Application.java +++ b/app/controllers/Application.java @@ -26,26 +26,20 @@ public static WebSocket ws() { public void onReady(final WebSocket.In in, final WebSocket.Out out) { // create a new UserActor and give it the default stocks to watch final ActorRef userActor = Akka.system().actorOf(Props.create(UserActor.class, out)); - + // send all WebSocket message to the UserActor - in.onMessage(new F.Callback() { - @Override - public void invoke(JsonNode jsonNode) throws Throwable { - // parse the JSON into WatchStock - WatchStock watchStock = new WatchStock(jsonNode.get("symbol").textValue()); - // send the watchStock message to the StocksActor - StocksActor.stocksActor().tell(watchStock, userActor); - } + in.onMessage(jsonNode -> { + // parse the JSON into WatchStock + WatchStock watchStock = new WatchStock(jsonNode.get("symbol").textValue()); + // send the watchStock message to the StocksActor + StocksActor.stocksActor().tell(watchStock, userActor); }); // on close, tell the userActor to shutdown - in.onClose(new F.Callback0() { - @Override - public void invoke() throws Throwable { - final Option none = Option.empty(); - StocksActor.stocksActor().tell(new UnwatchStock(none), userActor); - Akka.system().stop(userActor); - } + in.onClose(() -> { + final Option none = Option.empty(); + StocksActor.stocksActor().tell(new UnwatchStock(none), userActor); + Akka.system().stop(userActor); }); } }; From 1dab93d5c0d68c2688c2578cbc6c02386810df81 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:05:04 +1300 Subject: [PATCH 05/83] Convert scala stock actors and messages to java --- app/actors/Stock.java | 48 +++++++++++++++++++ app/actors/StockActor.java | 65 +++++++++++++++++++++++++ app/actors/StockActor.scala | 82 -------------------------------- app/actors/StocksActor.java | 47 ++++++++++++++++++ app/actors/UserActor.java | 28 +++++------ app/controllers/Application.java | 10 ++-- app/utils/FakeStockQuote.java | 24 ++++++++-- test/actors/StockActorSpec.scala | 35 +++++++------- test/actors/UserActorSpec.scala | 10 ++-- 9 files changed, 223 insertions(+), 126 deletions(-) create mode 100644 app/actors/Stock.java create mode 100644 app/actors/StockActor.java delete mode 100644 app/actors/StockActor.scala create mode 100644 app/actors/StocksActor.java diff --git a/app/actors/Stock.java b/app/actors/Stock.java new file mode 100644 index 000000000..7f3889143 --- /dev/null +++ b/app/actors/Stock.java @@ -0,0 +1,48 @@ +package actors; + +import java.util.Deque; +import java.util.Optional; + +public class Stock { + public static final class Latest { + public Latest() {} + } + + public static final Latest latest = new Latest(); + + public static final class Update { + public final String symbol; + public final Double price; + + public Update(String symbol, Double price) { + this.symbol = symbol; + this.price = price; + } + } + + public static final class History { + public final String symbol; + public final Deque history; + + public History(String symbol, Deque history) { + this.symbol = symbol; + this.history = history; + } + } + + public static final class Watch { + public final String symbol; + + public Watch(String symbol) { + this.symbol = symbol; + } + } + + public static final class Unwatch { + public final Optional symbol; + + public Unwatch(Optional symbol) { + this.symbol = symbol; + } + } +} diff --git a/app/actors/StockActor.java b/app/actors/StockActor.java new file mode 100644 index 000000000..f2171fd2c --- /dev/null +++ b/app/actors/StockActor.java @@ -0,0 +1,65 @@ +package actors; + +import akka.actor.ActorRef; +import akka.actor.Cancellable; +import akka.actor.UntypedActor; +import java.util.concurrent.TimeUnit; +import java.util.Deque; +import java.util.HashSet; +import scala.concurrent.duration.Duration; +import utils.FakeStockQuote; +import utils.StockQuote; + +/** + * There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock + * values. Each StockActor updates a rolling dataset of randomly generated stock values. + */ +public class StockActor extends UntypedActor { + + final String symbol; + + final StockQuote stockQuote; + + final HashSet watchers = new HashSet(); + + final Deque stockHistory = FakeStockQuote.history(50); + + public StockActor(String symbol) { + this.symbol = symbol; + this.stockQuote = new FakeStockQuote(); + } + + public StockActor(String symbol, StockQuote stockQuote) { + this.symbol = symbol; + this.stockQuote = stockQuote; + } + + // fetch the latest stock value every 75ms + Cancellable stockTick = getContext().system().scheduler().schedule( + Duration.Zero(), Duration.create(75, TimeUnit.MILLISECONDS), + getSelf(), Stock.latest, getContext().dispatcher(), null); + + public void onReceive(Object message) { + if (message instanceof Stock.Latest) { + // add a new stock price to the history and drop the oldest + Double newPrice = stockQuote.newPrice(stockHistory.peekLast()); + stockHistory.add(newPrice); + stockHistory.remove(); + // notify watchers + watchers.forEach(watcher -> watcher.tell(new Stock.Update(symbol, newPrice), getSelf())); + + } else if (message instanceof Stock.Watch) { + // send the stock history to the user + getSender().tell(new Stock.History(symbol, stockHistory), getSelf()); + // add the watcher to the list + watchers.add(getSender()); + + } else if (message instanceof Stock.Unwatch) { + watchers.remove(getSender()); + if (watchers.isEmpty()) { + stockTick.cancel(); + getContext().stop(getSelf()); + } + } + } +} diff --git a/app/actors/StockActor.scala b/app/actors/StockActor.scala deleted file mode 100644 index 821eda6e2..000000000 --- a/app/actors/StockActor.scala +++ /dev/null @@ -1,82 +0,0 @@ -package actors - -import akka.actor.{Props, ActorRef, Actor} -import utils.{StockQuote, FakeStockQuote} -import java.util.Random -import scala.collection.immutable.{HashSet, Queue} -import scala.collection.JavaConverters._ -import scala.concurrent.duration._ -import scala.concurrent.ExecutionContext.Implicits.global -import play.libs.Akka - -/** - * There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock - * values. Each StockActor updates a rolling dataset of randomly generated stock values. - */ - -class StockActor(symbol: String) extends Actor { - - lazy val stockQuote: StockQuote = new FakeStockQuote - - protected[this] var watchers: HashSet[ActorRef] = HashSet.empty[ActorRef] - - // A random data set which uses stockQuote.newPrice to get each data point - var stockHistory: Queue[java.lang.Double] = { - lazy val initialPrices: Stream[java.lang.Double] = (new Random().nextDouble * 800) #:: initialPrices.map(previous => stockQuote.newPrice(previous)) - initialPrices.take(50).to[Queue] - } - - // Fetch the latest stock value every 75ms - val stockTick = context.system.scheduler.schedule(Duration.Zero, 75.millis, self, FetchLatest) - - def receive = { - case FetchLatest => - // add a new stock price to the history and drop the oldest - val newPrice = stockQuote.newPrice(stockHistory.last.doubleValue()) - stockHistory = stockHistory.drop(1) :+ newPrice - // notify watchers - watchers.foreach(_ ! StockUpdate(symbol, newPrice)) - case WatchStock(_) => - // send the stock history to the user - sender ! StockHistory(symbol, stockHistory.asJava) - // add the watcher to the list - watchers = watchers + sender - case UnwatchStock(_) => - watchers = watchers - sender - if (watchers.size == 0) { - stockTick.cancel() - context.stop(self) - } - } -} - -class StocksActor extends Actor { - def receive = { - case watchStock @ WatchStock(symbol) => - // get or create the StockActor for the symbol and forward this message - context.child(symbol).getOrElse { - context.actorOf(Props(new StockActor(symbol)), symbol) - } forward watchStock - case unwatchStock @ UnwatchStock(Some(symbol)) => - // if there is a StockActor for the symbol forward this message - context.child(symbol).foreach(_.forward(unwatchStock)) - case unwatchStock @ UnwatchStock(None) => - // if no symbol is specified, forward to everyone - context.children.foreach(_.forward(unwatchStock)) - } -} - -object StocksActor { - lazy val stocksActor: ActorRef = Akka.system.actorOf(Props(classOf[StocksActor])) -} - - -case object FetchLatest - -case class StockUpdate(symbol: String, price: Number) - -case class StockHistory(symbol: String, history: java.util.List[java.lang.Double]) - -case class WatchStock(symbol: String) - -case class UnwatchStock(symbol: Option[String]) \ No newline at end of file diff --git a/app/actors/StocksActor.java b/app/actors/StocksActor.java new file mode 100644 index 000000000..08cb7331a --- /dev/null +++ b/app/actors/StocksActor.java @@ -0,0 +1,47 @@ +package actors; + +import akka.actor.ActorRef; +import akka.actor.Props; +import akka.actor.UntypedActor; +import play.libs.Akka; +import java.util.Optional; + +public class StocksActor extends UntypedActor { + + private static class LazyStocksActor { + public static final ActorRef ref = Akka.system().actorOf(Props.create(StocksActor.class)); + } + + public static ActorRef stocksActor() { + return LazyStocksActor.ref; + } + + public StocksActor() {} + + public void onReceive(Object message) { + if (message instanceof Stock.Watch) { + Stock.Watch watch = (Stock.Watch) message; + String symbol = watch.symbol; + // get or create the StockActor for the symbol and forward this message + ActorRef child = getContext().getChild(symbol); + if (child == null) { + child = getContext().actorOf(Props.create(StockActor.class, symbol), symbol); + } + child.forward(watch, getContext()); + + } else if (message instanceof Stock.Unwatch) { + Stock.Unwatch unwatch = (Stock.Unwatch) message; + Optional optionalSymbol = unwatch.symbol; + if (optionalSymbol.isPresent()) { + // if there is a StockActor for the symbol forward this message + String symbol = optionalSymbol.get(); + ActorRef child = getContext().getChild(symbol); + if (child != null) { + child.forward(unwatch, getContext()); + } + } else { // no symbol is specified, forward to everyone + getContext().getChildren().forEach(child -> child.forward(unwatch, getContext())); + } + } + } +} diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java index 34b610e9a..4617381ef 100644 --- a/app/actors/UserActor.java +++ b/app/actors/UserActor.java @@ -18,41 +18,41 @@ public class UserActor extends UntypedActor { private final WebSocket.Out out; - + public UserActor(WebSocket.Out out) { this.out = out; - + // watch the default stocks List defaultStocks = Play.application().configuration().getStringList("default.stocks"); for (String stockSymbol : defaultStocks) { - StocksActor.stocksActor().tell(new WatchStock(stockSymbol), getSelf()); + StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), getSelf()); } } - + public void onReceive(Object message) { - if (message instanceof StockUpdate) { + if (message instanceof Stock.Update) { // push the stock to the client - StockUpdate stockUpdate = (StockUpdate)message; + Stock.Update stockUpdate = (Stock.Update) message; ObjectNode stockUpdateMessage = Json.newObject(); stockUpdateMessage.put("type", "stockupdate"); - stockUpdateMessage.put("symbol", stockUpdate.symbol()); - stockUpdateMessage.put("price", stockUpdate.price().doubleValue()); + stockUpdateMessage.put("symbol", stockUpdate.symbol); + stockUpdateMessage.put("price", stockUpdate.price); out.write(stockUpdateMessage); } - else if (message instanceof StockHistory) { + else if (message instanceof Stock.History) { // push the history to the client - StockHistory stockHistory = (StockHistory)message; + Stock.History stockHistory = (Stock.History) message; ObjectNode stockUpdateMessage = Json.newObject(); stockUpdateMessage.put("type", "stockhistory"); - stockUpdateMessage.put("symbol", stockHistory.symbol()); + stockUpdateMessage.put("symbol", stockHistory.symbol); ArrayNode historyJson = stockUpdateMessage.putArray("history"); - for (Object price : stockHistory.history()) { - historyJson.add(((Number)price).doubleValue()); + for (Double price : stockHistory.history) { + historyJson.add(price); } - + out.write(stockUpdateMessage); } } diff --git a/app/controllers/Application.java b/app/controllers/Application.java index 7eaf56221..612a32d69 100644 --- a/app/controllers/Application.java +++ b/app/controllers/Application.java @@ -4,13 +4,12 @@ import akka.actor.*; import akka.actor.ActorRef; import com.fasterxml.jackson.databind.JsonNode; +import java.util.Optional; import play.libs.Akka; import play.libs.F; import play.mvc.Controller; import play.mvc.Result; import play.mvc.WebSocket; -import scala.Option; - /** * The main web controller that handles returning the index page, setting up a WebSocket, and watching a stock. @@ -29,16 +28,15 @@ public void onReady(final WebSocket.In in, final WebSocket.Out { - // parse the JSON into WatchStock - WatchStock watchStock = new WatchStock(jsonNode.get("symbol").textValue()); + // parse the JSON into Stock.Watch + Stock.Watch watchStock = new Stock.Watch(jsonNode.get("symbol").textValue()); // send the watchStock message to the StocksActor StocksActor.stocksActor().tell(watchStock, userActor); }); // on close, tell the userActor to shutdown in.onClose(() -> { - final Option none = Option.empty(); - StocksActor.stocksActor().tell(new UnwatchStock(none), userActor); + StocksActor.stocksActor().tell(new Stock.Unwatch(Optional.empty()), userActor); Akka.system().stop(userActor); }); } diff --git a/app/utils/FakeStockQuote.java b/app/utils/FakeStockQuote.java index 6f923f7c2..80c427523 100644 --- a/app/utils/FakeStockQuote.java +++ b/app/utils/FakeStockQuote.java @@ -1,15 +1,33 @@ package utils; +import java.util.Deque; +import java.util.LinkedList; import java.util.Random; /** - * Creates a randomly generated price based on the previous price + * Randomly generated prices. */ public class FakeStockQuote implements StockQuote { + private final Random random = new Random(); + + /** + * Creates a randomly generated price based on the previous price. + */ public Double newPrice(Double lastPrice) { - // todo: this trends towards zero - return lastPrice * (0.95 + (0.1 * new Random().nextDouble())); // lastPrice * (0.95 to 1.05) + return lastPrice * (0.95 + (0.1 * random.nextDouble())); } + /** + * Creates an initial history of random prices. + */ + public static Deque history(int length) { + FakeStockQuote stockQuote = new FakeStockQuote(); + LinkedList prices = new LinkedList(); + prices.add((new Random()).nextDouble() * 800); + for (int i = 1; i < length; i++) { + prices.add(stockQuote.newPrice(prices.peekLast())); + } + return prices; + } } diff --git a/test/actors/StockActorSpec.scala b/test/actors/StockActorSpec.scala index 9aa13b72e..8a2815417 100644 --- a/test/actors/StockActorSpec.scala +++ b/test/actors/StockActorSpec.scala @@ -22,11 +22,12 @@ class StockActorSpec extends TestkitExample with SpecificationLike with NoTimeCo */ sequential - final class StockActorWithStockQuote(symbol: String, price: Double, watcher: ActorRef) extends StockActor(symbol) { - watchers = HashSet[ActorRef](watcher) - override lazy val stockQuote = new StockQuote { - def newPrice(lastPrice: java.lang.Double): java.lang.Double = price - } + final class FixedStockQuote(price: java.lang.Double) extends StockQuote { + def newPrice(lastPrice: java.lang.Double): java.lang.Double = price + } + + final class StockActorWithStockQuote(symbol: String, price: Double, watcher: ActorRef) extends StockActor(symbol, new FixedStockQuote(price)) { + watchers.add(watcher) } "A StockActor" should { @@ -41,14 +42,16 @@ class StockActorSpec extends TestkitExample with SpecificationLike with NoTimeCo system.actorOf(Props(new ProbeWrapper(probe))) // Fire off the message... - stockActor ! FetchLatest + stockActor ! Stock.latest - // ... and ask the probe if it got the StockUpdate message. - val actualMessage = probe.receiveOne(500 millis) - val expectedMessage = StockUpdate(symbol, price) - actualMessage must ===(expectedMessage) + // ... and ask the probe if it got the Stock.Update message. + val actualMessage = probe.expectMsgType[Stock.Update](500 millis) + val expectedMessage = new Stock.Update(symbol, price) + actualMessage.symbol must ===(expectedMessage.symbol) + actualMessage.price must ===(expectedMessage.price) } - "add a watcher and send a StockHistory message to the user when receiving WatchStock message" in { + + "add a watcher and send a Stock.History message to the user when receiving WatchStock message" in { val probe = new TestProbe(system) // Create a standard StockActor. @@ -59,18 +62,18 @@ class StockActorSpec extends TestkitExample with SpecificationLike with NoTimeCo // Fire off the message, setting the sender as the UserActor // Simulates sending the message as if it was sent from the userActor - stockActor.tell(WatchStock(symbol), userActor) + stockActor.tell(new Stock.Watch(symbol), userActor) // the userActor will be added as a watcher and get a message with the stock history val userActorMessage = probe.receiveOne(500.millis) - userActorMessage must beAnInstanceOf[StockHistory] + userActorMessage must beAnInstanceOf[Stock.History] } } "A StocksActor" should { val symbol = "ABC" - "a WatchStock message should send a StockHistory message to the user" in { + "a Stock.Watch message should send a Stock.History message to the user" in { val probe = new TestProbe(system) val stockHolderActor = system.actorOf(Props[StocksActor]) @@ -79,11 +82,11 @@ class StockActorSpec extends TestkitExample with SpecificationLike with NoTimeCo // Fire off the message, setting the sender as the UserActor // Simulates sending the message as if it was sent from the userActor - stockHolderActor.tell(WatchStock(symbol), userActor) + stockHolderActor.tell(new Stock.Watch(symbol), userActor) // Should create a new stockActor as a child and send it the stock history val stockHistory = probe.receiveOne(500 millis) - stockHistory must beAnInstanceOf[StockHistory] + stockHistory must beAnInstanceOf[Stock.History] } } diff --git a/test/actors/UserActorSpec.scala b/test/actors/UserActorSpec.scala index f52015737..3eb28a343 100644 --- a/test/actors/UserActorSpec.scala +++ b/test/actors/UserActorSpec.scala @@ -28,16 +28,16 @@ class UserActorSpec extends TestkitExample with SpecificationLike with JsonMatch val symbol = "ABC" val price = 123 - val history = List[java.lang.Double](0.1, 1.0).asJava + val history = new java.util.LinkedList(List[java.lang.Double](0.1, 1.0).asJava) - "send a stock when receiving a StockUpdate message" in new WithApplication { + "send a stock when receiving a Stock.Update message" in new WithApplication { val out = new StubOut() val userActorRef = TestActorRef[UserActor](Props(new UserActor(out))) val userActor = userActorRef.underlyingActor // send off the stock update... - userActor.receive(StockUpdate(symbol, price)) + userActor.receive(new Stock.Update(symbol, price)) // ...and expect it to be a JSON node. val node = out.actual.toString @@ -46,14 +46,14 @@ class UserActorSpec extends TestkitExample with SpecificationLike with JsonMatch node must /("price" -> price) } - "send the stock history when receiving a StockHistory message" in new WithApplication { + "send the stock history when receiving a Stock.History message" in new WithApplication { val out = new StubOut() val userActorRef = TestActorRef[UserActor](Props(new UserActor(out))) val userActor = userActorRef.underlyingActor // send off the stock update... - userActor.receive(StockHistory(symbol, history)) + userActor.receive(new Stock.History(symbol, history)) // ...and expect it to be a JSON node. out.actual.get("type").asText must beEqualTo("stockhistory") From c6ac7500d5104a9095c25d3858cf5cdf270d3de5 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:05:20 +1300 Subject: [PATCH 06/83] Convert stock tweet and sentiment fetching to java --- app/controllers/StockSentiment.java | 68 ++++++++++++++++++++++++++ app/controllers/StockSentiment.scala | 71 ---------------------------- app/utils/Streams.java | 13 +++++ build.sbt | 12 +++-- conf/application.conf | 2 +- project/plugins.sbt | 4 +- 6 files changed, 91 insertions(+), 79 deletions(-) create mode 100644 app/controllers/StockSentiment.java delete mode 100644 app/controllers/StockSentiment.scala create mode 100644 app/utils/Streams.java diff --git a/app/controllers/StockSentiment.java b/app/controllers/StockSentiment.java new file mode 100644 index 000000000..4e46fa783 --- /dev/null +++ b/app/controllers/StockSentiment.java @@ -0,0 +1,68 @@ +package controllers; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.stream.Stream; +import play.libs.Json; +import play.libs.ws.WS; +import play.libs.ws.WSResponse; +import play.mvc.*; +import play.Play; + +import static java.util.stream.Collectors.averagingDouble; +import static java.util.stream.Collectors.toList; +import static play.libs.F.Promise; +import static utils.Streams.stream; + +public class StockSentiment extends Controller { + + public static Promise get(String symbol) { + return fetchTweets(symbol) + .flatMap(StockSentiment::fetchSentiments) + .map(StockSentiment::averageSentiment) + .map(Results::ok) + .recover(StockSentiment::errorResponse); + } + + public static Promise> fetchTweets(String symbol) { + return WS.url(Play.application().configuration().getString("tweet.url")) + .setQueryParameter("q", "$" + symbol).get() + .filter(response -> response.getStatus() == Http.Status.OK) + .map(response -> stream(response.asJson().findPath("statuses")) + .map(s -> s.findValue("text").asText()) + .collect(toList())); + } + + public static Promise> fetchSentiments(List tweets) { + String url = Play.application().configuration().getString("sentiment.url"); + Stream> sentiments = tweets.stream().map(text -> WS.url(url).post("text=" + text)); + return Promise.sequence(sentiments::iterator).map(StockSentiment::responsesAsJson); + } + + public static List responsesAsJson(List responses) { + return responses.stream().map(WSResponse::asJson).collect(toList()); + } + + public static JsonNode averageSentiment(List sentiments) { + double neg = collectAverage(sentiments, "neg"); + double neutral = collectAverage(sentiments, "neutral"); + double pos = collectAverage(sentiments, "pos"); + + String label = (neutral > 0.5) ? "neutral" : (neg > pos) ? "neg" : "pos"; + + return Json.newObject() + .put("label", label) + .set("probability", Json.newObject() + .put("neg", neg) + .put("neutral", neutral) + .put("pos", pos)); + } + + public static double collectAverage(List jsons, String label) { + return jsons.stream().collect(averagingDouble(json -> json.findValue(label).asDouble())); + } + + public static Result errorResponse(Throwable ignored) { + return internalServerError(Json.newObject().put("error", "Could not fetch the tweets")); + } +} diff --git a/app/controllers/StockSentiment.scala b/app/controllers/StockSentiment.scala deleted file mode 100644 index 08fe902e4..000000000 --- a/app/controllers/StockSentiment.scala +++ /dev/null @@ -1,71 +0,0 @@ -package controllers - -import scala.concurrent.ExecutionContext.Implicits.global -import play.api.mvc._ -import play.api.libs.ws.WS -import scala.concurrent.Future -import play.api.libs.json.{Json, JsValue} -import play.api.Play -import play.api.libs.ws.Response -import play.api.libs.json.JsString - -object StockSentiment extends Controller { - - case class Tweet(text: String) - - implicit val tweetReads = Json.reads[Tweet] - - def getTextSentiment(text: String): Future[Response] = - WS.url(Play.current.configuration.getString("sentiment.url").get) post Map("text" -> Seq(text)) - - def getAverageSentiment(responses: Seq[Response], label: String): Double = responses.map { response => - (response.json \\ label).head.as[Double] - }.sum / responses.length.max(1) // avoid division by zero - - def loadSentimentFromTweets(json: JsValue): Seq[Future[Response]] = - (json \ "statuses").as[Seq[Tweet]] map (tweet => getTextSentiment(tweet.text)) - - def getTweets(symbol:String): Future[Response] = { - WS.url(Play.current.configuration.getString("tweet.url").get.format(symbol)).get.withFilter { response => - response.status == OK - } - } - - - def sentimentJson(sentiments: Seq[Response]) = { - val neg = getAverageSentiment(sentiments, "neg") - val neutral = getAverageSentiment(sentiments, "neutral") - val pos = getAverageSentiment(sentiments, "pos") - - val response = Json.obj( - "probability" -> Json.obj( - "neg" -> neg, - "neutral" -> neutral, - "pos" -> pos - ) - ) - - val classification = - if (neutral > 0.5) - "neutral" - else if (neg > pos) - "neg" - else - "pos" - - response + ("label" -> JsString(classification)) - } - - def get(symbol: String): Action[AnyContent] = Action.async { - val futureStockSentiments: Future[SimpleResult] = for { - tweets <- getTweets(symbol) // get tweets that contain the stock symbol - futureSentiments = loadSentimentFromTweets(tweets.json) // queue web requests each tweets' sentiments - sentiments <- Future.sequence(futureSentiments) // when the sentiment responses arrive, set them - } yield Ok(sentimentJson(sentiments)) - - futureStockSentiments.recoverWith { - case nsee: NoSuchElementException => - Future(InternalServerError(Json.obj("error" -> JsString("Could not fetch the tweets")))) - } - } -} diff --git a/app/utils/Streams.java b/app/utils/Streams.java new file mode 100644 index 000000000..73324f393 --- /dev/null +++ b/app/utils/Streams.java @@ -0,0 +1,13 @@ +package utils; + +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +public class Streams { + /** + * Convert an Iterable to a Stream. + */ + public static Stream stream(Iterable iterable) { + return StreamSupport.stream(iterable.spliterator(), false); + } +} diff --git a/build.sbt b/build.sbt index 944aaaa77..18be6eb95 100644 --- a/build.sbt +++ b/build.sbt @@ -3,15 +3,17 @@ name := "reactive-stocks" version := "1.0-SNAPSHOT" libraryDependencies ++= Seq( - "com.typesafe.akka" %% "akka-actor" % "2.2.1", - "com.typesafe.akka" %% "akka-slf4j" % "2.2.1", - "org.webjars" %% "webjars-play" % "2.2.1", + javaWs, + //"com.typesafe.akka" %% "akka-actor" % "2.3.0-RC1", + //"com.typesafe.akka" %% "akka-slf4j" % "2.3.0-RC1", + "org.webjars" %% "webjars-play" % "2.3-SNAPSHOT", "org.webjars" % "bootstrap" % "2.3.1", "org.webjars" % "flot" % "0.8.0", - "com.typesafe.akka" %% "akka-testkit" % "2.2.1" % "test" + "org.specs2" %% "specs2-matcher-extra" % "2.3.7" % "test", + "com.typesafe.akka" %% "akka-testkit" % "2.3.0-RC1" % "test" ) -play.Project.playScalaSettings +play.Project.playJavaSettings javacOptions ++= Seq("-source", "1.8") diff --git a/conf/application.conf b/conf/application.conf index 32436152f..98bc5b5b0 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -71,4 +71,4 @@ logger.application=DEBUG default.stocks=["GOOG", "AAPL", "ORCL"] sentiment.url="http://text-processing.com/api/sentiment/" -tweet.url="http://twitter-search-proxy.herokuapp.com/search/tweets?q=%%24%s" \ No newline at end of file +tweet.url="http://twitter-search-proxy.herokuapp.com/search/tweets" \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index ba7a57acd..8bfc31346 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,8 +1,8 @@ // Comment to get more information during initialization logLevel := Level.Warn -// The Typesafe repository +// The Typesafe repository resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" // Use the Play sbt plugin for Play projects -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.2.1") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-SNAPSHOT") From 775d3468cf788b51931352cf6707bc5c4772f955 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:05:38 +1300 Subject: [PATCH 07/83] Convert actors to lambda actors --- app/actors/StockActor.java | 39 +++++++++++++------------- app/actors/StocksActor.java | 38 +++++++++++-------------- app/actors/UserActor.java | 49 ++++++++++++++++----------------- app/utils/FakeStockQuote.java | 2 +- app/utils/Functions.java | 27 ++++++++++++++++++ app/utils/LambdaActor.java | 20 ++++++++++++++ test/actors/UserActorSpec.scala | 6 ++-- 7 files changed, 108 insertions(+), 73 deletions(-) create mode 100644 app/utils/Functions.java create mode 100644 app/utils/LambdaActor.java diff --git a/app/actors/StockActor.java b/app/actors/StockActor.java index f2171fd2c..53bbb54c0 100644 --- a/app/actors/StockActor.java +++ b/app/actors/StockActor.java @@ -2,19 +2,19 @@ import akka.actor.ActorRef; import akka.actor.Cancellable; -import akka.actor.UntypedActor; import java.util.concurrent.TimeUnit; import java.util.Deque; import java.util.HashSet; import scala.concurrent.duration.Duration; import utils.FakeStockQuote; import utils.StockQuote; +import utils.LambdaActor; /** * There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock * values. Each StockActor updates a rolling dataset of randomly generated stock values. */ -public class StockActor extends UntypedActor { +public class StockActor extends LambdaActor { final String symbol; @@ -24,42 +24,41 @@ public class StockActor extends UntypedActor { final Deque stockHistory = FakeStockQuote.history(50); + // fetch the latest stock value every 75ms + Cancellable stockTick = context().system().scheduler().schedule( + Duration.Zero(), Duration.create(75, TimeUnit.MILLISECONDS), + self(), Stock.latest, context().dispatcher(), null); + public StockActor(String symbol) { - this.symbol = symbol; - this.stockQuote = new FakeStockQuote(); + this(symbol, new FakeStockQuote()); } public StockActor(String symbol, StockQuote stockQuote) { this.symbol = symbol; this.stockQuote = stockQuote; - } - - // fetch the latest stock value every 75ms - Cancellable stockTick = getContext().system().scheduler().schedule( - Duration.Zero(), Duration.create(75, TimeUnit.MILLISECONDS), - getSelf(), Stock.latest, getContext().dispatcher(), null); - public void onReceive(Object message) { - if (message instanceof Stock.Latest) { + receive(Stock.Latest.class, latest -> { // add a new stock price to the history and drop the oldest Double newPrice = stockQuote.newPrice(stockHistory.peekLast()); stockHistory.add(newPrice); stockHistory.remove(); // notify watchers - watchers.forEach(watcher -> watcher.tell(new Stock.Update(symbol, newPrice), getSelf())); + watchers.forEach(watcher -> watcher.tell(new Stock.Update(symbol, newPrice), self())); + }); - } else if (message instanceof Stock.Watch) { + receive(Stock.Watch.class, watch -> { // send the stock history to the user - getSender().tell(new Stock.History(symbol, stockHistory), getSelf()); + sender().tell(new Stock.History(symbol, stockHistory), self()); // add the watcher to the list - watchers.add(getSender()); + watchers.add(sender()); + }); - } else if (message instanceof Stock.Unwatch) { - watchers.remove(getSender()); + receive(Stock.Unwatch.class, unwatch -> { + watchers.remove(sender()); if (watchers.isEmpty()) { stockTick.cancel(); - getContext().stop(getSelf()); + context().stop(self()); } - } + }); } } diff --git a/app/actors/StocksActor.java b/app/actors/StocksActor.java index 08cb7331a..a13dc7305 100644 --- a/app/actors/StocksActor.java +++ b/app/actors/StocksActor.java @@ -2,11 +2,14 @@ import akka.actor.ActorRef; import akka.actor.Props; -import akka.actor.UntypedActor; -import play.libs.Akka; import java.util.Optional; +import play.libs.Akka; +import utils.LambdaActor; + +import static utils.Functions.consumer; +import static utils.Functions.supplier; -public class StocksActor extends UntypedActor { +public class StocksActor extends LambdaActor { private static class LazyStocksActor { public static final ActorRef ref = Akka.system().actorOf(Props.create(StocksActor.class)); @@ -16,32 +19,23 @@ public static ActorRef stocksActor() { return LazyStocksActor.ref; } - public StocksActor() {} - - public void onReceive(Object message) { - if (message instanceof Stock.Watch) { - Stock.Watch watch = (Stock.Watch) message; + public StocksActor() { + receive(Stock.Watch.class, watch -> { String symbol = watch.symbol; // get or create the StockActor for the symbol and forward this message - ActorRef child = getContext().getChild(symbol); - if (child == null) { - child = getContext().actorOf(Props.create(StockActor.class, symbol), symbol); - } - child.forward(watch, getContext()); + context().child(symbol).getOrElse(supplier( + () -> context().actorOf(Props.create(StockActor.class, symbol), symbol) + )).forward(watch, context()); + }); - } else if (message instanceof Stock.Unwatch) { - Stock.Unwatch unwatch = (Stock.Unwatch) message; + receive(Stock.Unwatch.class, unwatch -> { Optional optionalSymbol = unwatch.symbol; if (optionalSymbol.isPresent()) { // if there is a StockActor for the symbol forward this message - String symbol = optionalSymbol.get(); - ActorRef child = getContext().getChild(symbol); - if (child != null) { - child.forward(unwatch, getContext()); - } + context().child(optionalSymbol.get()).foreach(consumer(child -> child.forward(unwatch, context()))); } else { // no symbol is specified, forward to everyone - getContext().getChildren().forEach(child -> child.forward(unwatch, getContext())); + context().children().foreach(consumer(child -> child.forward(unwatch, context()))); } - } + }); } } diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java index 4617381ef..93a823365 100644 --- a/app/actors/UserActor.java +++ b/app/actors/UserActor.java @@ -1,23 +1,22 @@ package actors; -import akka.actor.UntypedActor; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import play.Play; +import java.util.List; import play.libs.Json; import play.mvc.WebSocket; - -import java.util.List; +import play.Play; +import utils.LambdaActor; /** * The broker between the WebSocket and the StockActor(s). The UserActor holds the connection and sends serialized * JSON data to the client. */ -public class UserActor extends UntypedActor { +public class UserActor extends LambdaActor { - private final WebSocket.Out out; + final WebSocket.Out out; public UserActor(WebSocket.Out out) { this.out = out; @@ -26,34 +25,32 @@ public UserActor(WebSocket.Out out) { List defaultStocks = Play.application().configuration().getStringList("default.stocks"); for (String stockSymbol : defaultStocks) { - StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), getSelf()); + StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), self()); } - } - public void onReceive(Object message) { - if (message instanceof Stock.Update) { + receive(Stock.Update.class, stockUpdate -> { // push the stock to the client - Stock.Update stockUpdate = (Stock.Update) message; - ObjectNode stockUpdateMessage = Json.newObject(); - stockUpdateMessage.put("type", "stockupdate"); - stockUpdateMessage.put("symbol", stockUpdate.symbol); - stockUpdateMessage.put("price", stockUpdate.price); - out.write(stockUpdateMessage); - } - else if (message instanceof Stock.History) { + JsonNode message = + Json.newObject() + .put("type", "stockupdate") + .put("symbol", stockUpdate.symbol) + .put("price", stockUpdate.price); + out.write(message); + }); + + receive(Stock.History.class, stockHistory -> { // push the history to the client - Stock.History stockHistory = (Stock.History) message; + ObjectNode message = + Json.newObject() + .put("type", "stockhistory") + .put("symbol", stockHistory.symbol); - ObjectNode stockUpdateMessage = Json.newObject(); - stockUpdateMessage.put("type", "stockhistory"); - stockUpdateMessage.put("symbol", stockHistory.symbol); - - ArrayNode historyJson = stockUpdateMessage.putArray("history"); + ArrayNode historyJson = message.putArray("history"); for (Double price : stockHistory.history) { historyJson.add(price); } - out.write(stockUpdateMessage); - } + out.write(message); + }); } } diff --git a/app/utils/FakeStockQuote.java b/app/utils/FakeStockQuote.java index 80c427523..ab7541d9f 100644 --- a/app/utils/FakeStockQuote.java +++ b/app/utils/FakeStockQuote.java @@ -23,7 +23,7 @@ public Double newPrice(Double lastPrice) { */ public static Deque history(int length) { FakeStockQuote stockQuote = new FakeStockQuote(); - LinkedList prices = new LinkedList(); + Deque prices = new LinkedList(); prices.add((new Random()).nextDouble() * 800); for (int i = 1; i < length; i++) { prices.add(stockQuote.newPrice(prices.peekLast())); diff --git a/app/utils/Functions.java b/app/utils/Functions.java new file mode 100644 index 000000000..1c7bcc7f2 --- /dev/null +++ b/app/utils/Functions.java @@ -0,0 +1,27 @@ +package utils; + +import java.util.function.Consumer; +import java.util.function.Supplier; +import scala.runtime.AbstractFunction0; +import scala.runtime.AbstractFunction1; +import scala.runtime.BoxedUnit; + +public class Functions { + /** + * Convert Supplier to Scala (=> A). + */ + public static AbstractFunction0 supplier(Supplier s) { + return new AbstractFunction0() { + public A apply() { return s.get(); } + }; + } + + /** + * Convert Consumer to Scala (A => Unit). + */ + public static AbstractFunction1 consumer(Consumer c) { + return new AbstractFunction1() { + public BoxedUnit apply(A a) { c.accept(a); return BoxedUnit.UNIT; } + }; + } +} diff --git a/app/utils/LambdaActor.java b/app/utils/LambdaActor.java new file mode 100644 index 000000000..5acf6305e --- /dev/null +++ b/app/utils/LambdaActor.java @@ -0,0 +1,20 @@ +package utils; + +import akka.actor.AbstractActor; +import akka.japi.pf.FI; +import akka.japi.pf.ReceiveBuilder; +import akka.japi.pf.UnitPFBuilder; +import scala.PartialFunction; +import scala.runtime.BoxedUnit; + +public class LambdaActor extends AbstractActor { + private UnitPFBuilder receiveBuilder = new UnitPFBuilder(); + + public void receive(final Class type, FI.UnitApply apply) { + receiveBuilder = receiveBuilder.match(type, apply); + } + + public PartialFunction receive() { + return receiveBuilder.build(); + } +} diff --git a/test/actors/UserActorSpec.scala b/test/actors/UserActorSpec.scala index 3eb28a343..2dcac9698 100644 --- a/test/actors/UserActorSpec.scala +++ b/test/actors/UserActorSpec.scala @@ -33,8 +33,7 @@ class UserActorSpec extends TestkitExample with SpecificationLike with JsonMatch "send a stock when receiving a Stock.Update message" in new WithApplication { val out = new StubOut() - val userActorRef = TestActorRef[UserActor](Props(new UserActor(out))) - val userActor = userActorRef.underlyingActor + val userActor = TestActorRef[UserActor](Props(new UserActor(out))) // send off the stock update... userActor.receive(new Stock.Update(symbol, price)) @@ -49,8 +48,7 @@ class UserActorSpec extends TestkitExample with SpecificationLike with JsonMatch "send the stock history when receiving a Stock.History message" in new WithApplication { val out = new StubOut() - val userActorRef = TestActorRef[UserActor](Props(new UserActor(out))) - val userActor = userActorRef.underlyingActor + val userActor = TestActorRef[UserActor](Props(new UserActor(out))) // send off the stock update... userActor.receive(new Stock.History(symbol, history)) From 86e5ff28dd9b470d8cc59c3d333ce3835bd21b7f Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:05:54 +1300 Subject: [PATCH 08/83] Provide the untyped actor context in lambda actors --- app/actors/StocksActor.java | 21 +++++++++------------ app/utils/Functions.java | 27 --------------------------- app/utils/LambdaActor.java | 10 ++++++++++ 3 files changed, 19 insertions(+), 39 deletions(-) delete mode 100644 app/utils/Functions.java diff --git a/app/actors/StocksActor.java b/app/actors/StocksActor.java index a13dc7305..f727eb7dd 100644 --- a/app/actors/StocksActor.java +++ b/app/actors/StocksActor.java @@ -2,13 +2,11 @@ import akka.actor.ActorRef; import akka.actor.Props; +import java.util.Collections; import java.util.Optional; import play.libs.Akka; import utils.LambdaActor; -import static utils.Functions.consumer; -import static utils.Functions.supplier; - public class StocksActor extends LambdaActor { private static class LazyStocksActor { @@ -23,19 +21,18 @@ public StocksActor() { receive(Stock.Watch.class, watch -> { String symbol = watch.symbol; // get or create the StockActor for the symbol and forward this message - context().child(symbol).getOrElse(supplier( + Optional.ofNullable(getContext().getChild(symbol)).orElseGet( () -> context().actorOf(Props.create(StockActor.class, symbol), symbol) - )).forward(watch, context()); + ).forward(watch, context()); }); receive(Stock.Unwatch.class, unwatch -> { - Optional optionalSymbol = unwatch.symbol; - if (optionalSymbol.isPresent()) { - // if there is a StockActor for the symbol forward this message - context().child(optionalSymbol.get()).foreach(consumer(child -> child.forward(unwatch, context()))); - } else { // no symbol is specified, forward to everyone - context().children().foreach(consumer(child -> child.forward(unwatch, context()))); - } + // forward this message to the associated StockActor, or otherwise to everyone + unwatch.symbol + .map(getContext()::getChild) + .>map(Collections::singletonList) + .orElse(getContext().getChildren()) + .forEach(child -> child.forward(unwatch, context())); }); } } diff --git a/app/utils/Functions.java b/app/utils/Functions.java deleted file mode 100644 index 1c7bcc7f2..000000000 --- a/app/utils/Functions.java +++ /dev/null @@ -1,27 +0,0 @@ -package utils; - -import java.util.function.Consumer; -import java.util.function.Supplier; -import scala.runtime.AbstractFunction0; -import scala.runtime.AbstractFunction1; -import scala.runtime.BoxedUnit; - -public class Functions { - /** - * Convert Supplier to Scala (=> A). - */ - public static AbstractFunction0 supplier(Supplier s) { - return new AbstractFunction0() { - public A apply() { return s.get(); } - }; - } - - /** - * Convert Consumer to Scala (A => Unit). - */ - public static AbstractFunction1 consumer(Consumer c) { - return new AbstractFunction1() { - public BoxedUnit apply(A a) { c.accept(a); return BoxedUnit.UNIT; } - }; - } -} diff --git a/app/utils/LambdaActor.java b/app/utils/LambdaActor.java index 5acf6305e..4f3d20c59 100644 --- a/app/utils/LambdaActor.java +++ b/app/utils/LambdaActor.java @@ -1,13 +1,19 @@ package utils; import akka.actor.AbstractActor; +import akka.actor.UntypedActorContext; import akka.japi.pf.FI; import akka.japi.pf.ReceiveBuilder; import akka.japi.pf.UnitPFBuilder; import scala.PartialFunction; import scala.runtime.BoxedUnit; +/** + * An actor that allows receive builder matches to be specified in the constructor, + * avoiding boilerplate and Scala types. Also provides the Java actor context. + */ public class LambdaActor extends AbstractActor { + private UnitPFBuilder receiveBuilder = new UnitPFBuilder(); public void receive(final Class type, FI.UnitApply apply) { @@ -17,4 +23,8 @@ public void receive(final Class type, FI.UnitApply apply) { public PartialFunction receive() { return receiveBuilder.build(); } + + public UntypedActorContext getContext() { + return (UntypedActorContext) context(); + } } From 65ff24f7ec2c4986068122ac7224bdd944301d05 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 16:06:09 +1300 Subject: [PATCH 09/83] Convert tests to java --- app/actors/StockActor.java | 30 +++++------ app/actors/UserActor.java | 14 ----- app/controllers/Application.java | 6 +++ build.sbt | 3 +- test/actors/ProbeWrapper.scala | 13 ----- test/actors/StockActorSpec.scala | 93 -------------------------------- test/actors/StockActorTest.java | 64 ++++++++++++++++++++++ test/actors/StubOut.java | 18 +++++++ test/actors/StubOut.scala | 18 ------- test/actors/TestkitExample.scala | 30 ----------- test/actors/UserActorSpec.scala | 63 ---------------------- test/actors/UserActorTest.java | 70 ++++++++++++++++++++++++ 12 files changed, 172 insertions(+), 250 deletions(-) delete mode 100644 test/actors/ProbeWrapper.scala delete mode 100644 test/actors/StockActorSpec.scala create mode 100644 test/actors/StockActorTest.java create mode 100644 test/actors/StubOut.java delete mode 100644 test/actors/StubOut.scala delete mode 100644 test/actors/TestkitExample.scala delete mode 100644 test/actors/UserActorSpec.scala create mode 100644 test/actors/UserActorTest.java diff --git a/app/actors/StockActor.java b/app/actors/StockActor.java index 53bbb54c0..8aea1e144 100644 --- a/app/actors/StockActor.java +++ b/app/actors/StockActor.java @@ -5,10 +5,11 @@ import java.util.concurrent.TimeUnit; import java.util.Deque; import java.util.HashSet; +import java.util.Optional; import scala.concurrent.duration.Duration; import utils.FakeStockQuote; -import utils.StockQuote; import utils.LambdaActor; +import utils.StockQuote; /** * There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock @@ -16,26 +17,16 @@ */ public class StockActor extends LambdaActor { - final String symbol; - - final StockQuote stockQuote; - final HashSet watchers = new HashSet(); final Deque stockHistory = FakeStockQuote.history(50); - // fetch the latest stock value every 75ms - Cancellable stockTick = context().system().scheduler().schedule( - Duration.Zero(), Duration.create(75, TimeUnit.MILLISECONDS), - self(), Stock.latest, context().dispatcher(), null); - public StockActor(String symbol) { - this(symbol, new FakeStockQuote()); + this(symbol, new FakeStockQuote(), true); } - public StockActor(String symbol, StockQuote stockQuote) { - this.symbol = symbol; - this.stockQuote = stockQuote; + public StockActor(String symbol, StockQuote stockQuote, boolean tick) { + Optional stockTick = tick ? Optional.of(scheduleTick()) : Optional.empty(); receive(Stock.Latest.class, latest -> { // add a new stock price to the history and drop the oldest @@ -47,18 +38,23 @@ public StockActor(String symbol, StockQuote stockQuote) { }); receive(Stock.Watch.class, watch -> { - // send the stock history to the user + // reply with the stock history, and add the sender as a watcher sender().tell(new Stock.History(symbol, stockHistory), self()); - // add the watcher to the list watchers.add(sender()); }); receive(Stock.Unwatch.class, unwatch -> { watchers.remove(sender()); if (watchers.isEmpty()) { - stockTick.cancel(); + stockTick.ifPresent(Cancellable::cancel); context().stop(self()); } }); } + + private Cancellable scheduleTick() { + return context().system().scheduler().schedule( + Duration.Zero(), Duration.create(75, TimeUnit.MILLISECONDS), + self(), Stock.latest, context().dispatcher(), null); + } } diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java index 93a823365..f5b01cd65 100644 --- a/app/actors/UserActor.java +++ b/app/actors/UserActor.java @@ -3,31 +3,17 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import java.util.List; import play.libs.Json; import play.mvc.WebSocket; -import play.Play; import utils.LambdaActor; /** * The broker between the WebSocket and the StockActor(s). The UserActor holds the connection and sends serialized * JSON data to the client. */ - public class UserActor extends LambdaActor { - final WebSocket.Out out; - public UserActor(WebSocket.Out out) { - this.out = out; - - // watch the default stocks - List defaultStocks = Play.application().configuration().getStringList("default.stocks"); - - for (String stockSymbol : defaultStocks) { - StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), self()); - } - receive(Stock.Update.class, stockUpdate -> { // push the stock to the client JsonNode message = diff --git a/app/controllers/Application.java b/app/controllers/Application.java index 612a32d69..d68ea071b 100644 --- a/app/controllers/Application.java +++ b/app/controllers/Application.java @@ -4,12 +4,14 @@ import akka.actor.*; import akka.actor.ActorRef; import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; import java.util.Optional; import play.libs.Akka; import play.libs.F; import play.mvc.Controller; import play.mvc.Result; import play.mvc.WebSocket; +import play.Play; /** * The main web controller that handles returning the index page, setting up a WebSocket, and watching a stock. @@ -25,6 +27,10 @@ public static WebSocket ws() { public void onReady(final WebSocket.In in, final WebSocket.Out out) { // create a new UserActor and give it the default stocks to watch final ActorRef userActor = Akka.system().actorOf(Props.create(UserActor.class, out)); + List defaultStocks = Play.application().configuration().getStringList("default.stocks"); + for (String stockSymbol : defaultStocks) { + StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), userActor); + } // send all WebSocket message to the UserActor in.onMessage(jsonNode -> { diff --git a/build.sbt b/build.sbt index 18be6eb95..552c330b7 100644 --- a/build.sbt +++ b/build.sbt @@ -9,13 +9,12 @@ libraryDependencies ++= Seq( "org.webjars" %% "webjars-play" % "2.3-SNAPSHOT", "org.webjars" % "bootstrap" % "2.3.1", "org.webjars" % "flot" % "0.8.0", - "org.specs2" %% "specs2-matcher-extra" % "2.3.7" % "test", "com.typesafe.akka" %% "akka-testkit" % "2.3.0-RC1" % "test" ) play.Project.playJavaSettings -javacOptions ++= Seq("-source", "1.8") +javacOptions ++= Seq("-source", "1.8", "-target", "1.8", "-Xlint") initialize := { val _ = initialize.value diff --git a/test/actors/ProbeWrapper.scala b/test/actors/ProbeWrapper.scala deleted file mode 100644 index e8b48e93c..000000000 --- a/test/actors/ProbeWrapper.scala +++ /dev/null @@ -1,13 +0,0 @@ -package actors - -import akka.actor.Actor -import akka.testkit.TestProbe - -/** - * A wrapper around a TestProbe that we can inject into actors. - */ -class ProbeWrapper(probe: TestProbe) extends Actor { - def receive = { - case x => probe.ref forward x - } -} diff --git a/test/actors/StockActorSpec.scala b/test/actors/StockActorSpec.scala deleted file mode 100644 index 8a2815417..000000000 --- a/test/actors/StockActorSpec.scala +++ /dev/null @@ -1,93 +0,0 @@ -package actors - -import akka.actor._ -import akka.testkit._ - -import org.specs2.mutable._ -import org.specs2.time.NoTimeConversions - -import scala.concurrent.duration._ -import scala.collection.immutable.HashSet - -import utils.StockQuote - -class StockActorSpec extends TestkitExample with SpecificationLike with NoTimeConversions { - - /* - * Running tests in parallel (which would ordinarily be the default) will work only if no - * shared resources are used (e.g. top-level actors with the same name or the - * system.eventStream). - * - * It's usually safer to run the tests sequentially. - */ - sequential - - final class FixedStockQuote(price: java.lang.Double) extends StockQuote { - def newPrice(lastPrice: java.lang.Double): java.lang.Double = price - } - - final class StockActorWithStockQuote(symbol: String, price: Double, watcher: ActorRef) extends StockActor(symbol, new FixedStockQuote(price)) { - watchers.add(watcher) - } - - "A StockActor" should { - val symbol = "ABC" - - "notify watchers when a new stock is received" in { - // Create a stock actor with a stubbed out stockquote price and watcher - val probe = new TestProbe(system) - val price = 1234.0 - val stockActor = system.actorOf(Props(new StockActorWithStockQuote(symbol, price, probe.ref))) - - system.actorOf(Props(new ProbeWrapper(probe))) - - // Fire off the message... - stockActor ! Stock.latest - - // ... and ask the probe if it got the Stock.Update message. - val actualMessage = probe.expectMsgType[Stock.Update](500 millis) - val expectedMessage = new Stock.Update(symbol, price) - actualMessage.symbol must ===(expectedMessage.symbol) - actualMessage.price must ===(expectedMessage.price) - } - - "add a watcher and send a Stock.History message to the user when receiving WatchStock message" in { - val probe = new TestProbe(system) - - // Create a standard StockActor. - val stockActor = system.actorOf(Props(new StockActor(symbol))) - - // create an actor which will test the UserActor - val userActor = system.actorOf(Props(new ProbeWrapper(probe))) - - // Fire off the message, setting the sender as the UserActor - // Simulates sending the message as if it was sent from the userActor - stockActor.tell(new Stock.Watch(symbol), userActor) - - // the userActor will be added as a watcher and get a message with the stock history - val userActorMessage = probe.receiveOne(500.millis) - userActorMessage must beAnInstanceOf[Stock.History] - } - } - - "A StocksActor" should { - val symbol = "ABC" - - "a Stock.Watch message should send a Stock.History message to the user" in { - val probe = new TestProbe(system) - val stockHolderActor = system.actorOf(Props[StocksActor]) - - // create an actor which will test the UserActor - val userActor = system.actorOf(Props(new ProbeWrapper(probe))) - - // Fire off the message, setting the sender as the UserActor - // Simulates sending the message as if it was sent from the userActor - stockHolderActor.tell(new Stock.Watch(symbol), userActor) - - // Should create a new stockActor as a child and send it the stock history - val stockHistory = probe.receiveOne(500 millis) - stockHistory must beAnInstanceOf[Stock.History] - } - - } -} diff --git a/test/actors/StockActorTest.java b/test/actors/StockActorTest.java new file mode 100644 index 000000000..ed6c3f6f3 --- /dev/null +++ b/test/actors/StockActorTest.java @@ -0,0 +1,64 @@ +package actors; + +import akka.actor.ActorRef; +import akka.actor.ActorSystem; +import akka.actor.Props; +import akka.testkit.JavaTestKit; +import akka.testkit.TestActorRef; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import utils.StockQuote; + +import static play.test.Helpers.fakeApplication; +import static play.test.Helpers.running; +import static org.fest.assertions.Assertions.assertThat; + +public class StockActorTest { + + static ActorSystem system; + + @BeforeClass + public static void setup() { + system = ActorSystem.create("UserActorTest"); + } + + @AfterClass + public static void teardown() { + JavaTestKit.shutdownActorSystem(system); + system = null; + } + + public static class FixedStockQuote implements StockQuote { + Double price; + + public FixedStockQuote(Double price) { + this.price = price; + } + + public Double newPrice(Double lastPrice) { + return price; + } + } + + @Test + public void stockActorShouldNotifyWatchers() { + running(fakeApplication(), () -> new JavaTestKit(system) {{ + String symbol = "ABC"; + double price = 1234; + + Props props = Props.create(StockActor.class, symbol, new FixedStockQuote(price), /*tick = */ false); + ActorRef stockActor = system.actorOf(props, "stockActor"); + + // receive Stock.History when adding a watcher with Stock.Watch + stockActor.tell(new Stock.Watch(symbol), getRef()); + Stock.History history = expectMsgClass(Stock.History.class); + + // receive Stock.Update on Stock.Latest tick + stockActor.tell(Stock.latest, getRef()); + Stock.Update update = expectMsgClass(Stock.Update.class); + assertThat(update.symbol).isEqualTo(symbol); + assertThat(update.price).isEqualTo(price); + }}); + } +} diff --git a/test/actors/StubOut.java b/test/actors/StubOut.java new file mode 100644 index 000000000..9d43fe386 --- /dev/null +++ b/test/actors/StubOut.java @@ -0,0 +1,18 @@ +package actors; + +import com.fasterxml.jackson.databind.JsonNode; +import play.mvc.WebSocket; + +/** + * A stub class that looks like WebSocket.Out to the rest of the system, and + * returns the actual results of the test to check against our expectations. + */ +public class StubOut implements WebSocket.Out { + public JsonNode actual; + + public void write(JsonNode node) { + actual = node; + } + + public void close() {} +} diff --git a/test/actors/StubOut.scala b/test/actors/StubOut.scala deleted file mode 100644 index 717d80e3e..000000000 --- a/test/actors/StubOut.scala +++ /dev/null @@ -1,18 +0,0 @@ -package actors - -import play.mvc.WebSocket -import com.fasterxml.jackson.databind.JsonNode - -/** - * A stub class that looks like WebSocket.Out to the rest of the system, and - * returns the actual results of the test to check against our expectations. - */ -class StubOut() extends WebSocket.Out[JsonNode]() { - var actual: JsonNode = null - - def write(node: JsonNode) { - actual = node - } - - def close() {} -} diff --git a/test/actors/TestkitExample.scala b/test/actors/TestkitExample.scala deleted file mode 100644 index dd2fa22ce..000000000 --- a/test/actors/TestkitExample.scala +++ /dev/null @@ -1,30 +0,0 @@ -package actors - -import akka.actor._ -import akka.testkit._ - -import org.specs2.specification.AfterExample - -/** - * This class provides any enclosed specs with an ActorSystem and an implicit sender. - * An ActorSystem can be an expensive thing to set up, so we define a single system - * that is used for all of the tests. - */ -abstract class TestkitExample extends TestKit(ActorSystem()) -with AfterExample -with ImplicitSender { - - /** - * Runs after the example completes. - */ - def after { - // Send a shutdown message to all actors. - system.shutdown() - - // Block the current thread until all the actors have received and processed - // shutdown messages. Using this method makes certain that all threads have been - // terminated, which is especially important when running large test suites (otherwise - // you may find yourself running out of threads unexpectedly) - system.awaitTermination() - } -} diff --git a/test/actors/UserActorSpec.scala b/test/actors/UserActorSpec.scala deleted file mode 100644 index 2dcac9698..000000000 --- a/test/actors/UserActorSpec.scala +++ /dev/null @@ -1,63 +0,0 @@ -package actors - -import akka.actor._ -import akka.testkit._ - -import org.specs2.mutable._ -import org.specs2.time.NoTimeConversions - -import scala.concurrent.duration._ - -import scala.collection.JavaConverters._ -import play.api.test.WithApplication -import org.specs2.matcher.JsonMatchers - -class UserActorSpec extends TestkitExample with SpecificationLike with JsonMatchers with NoTimeConversions { - - /* - * Running tests in parallel (which would ordinarily be the default) will work only if no - * shared resources are used (e.g. top-level actors with the same name or the - * system.eventStream). - * - * It's usually safer to run the tests sequentially. - */ - - sequential - - "UserActor" should { - - val symbol = "ABC" - val price = 123 - val history = new java.util.LinkedList(List[java.lang.Double](0.1, 1.0).asJava) - - "send a stock when receiving a Stock.Update message" in new WithApplication { - val out = new StubOut() - - val userActor = TestActorRef[UserActor](Props(new UserActor(out))) - - // send off the stock update... - userActor.receive(new Stock.Update(symbol, price)) - - // ...and expect it to be a JSON node. - val node = out.actual.toString - node must /("type" -> "stockupdate") - node must /("symbol" -> symbol) - node must /("price" -> price) - } - - "send the stock history when receiving a Stock.History message" in new WithApplication { - val out = new StubOut() - - val userActor = TestActorRef[UserActor](Props(new UserActor(out))) - - // send off the stock update... - userActor.receive(new Stock.History(symbol, history)) - - // ...and expect it to be a JSON node. - out.actual.get("type").asText must beEqualTo("stockhistory") - out.actual.get("symbol").asText must beEqualTo(symbol) - out.actual.get("history").get(0).asDouble must beEqualTo(history.get(0)) - } - } - -} diff --git a/test/actors/UserActorTest.java b/test/actors/UserActorTest.java new file mode 100644 index 000000000..46dbd695b --- /dev/null +++ b/test/actors/UserActorTest.java @@ -0,0 +1,70 @@ +package actors; + +import akka.actor.ActorSystem; +import akka.actor.Props; +import akka.testkit.JavaTestKit; +import akka.testkit.TestActorRef; +import java.util.Arrays; +import java.util.Deque; +import java.util.LinkedList; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import static play.test.Helpers.fakeApplication; +import static play.test.Helpers.running; +import static org.fest.assertions.Assertions.assertThat; + +public class UserActorTest { + + static StubOut out; + static ActorSystem system; + static TestActorRef userActor; + + @BeforeClass + public static void setup() { + out = new StubOut(); + system = ActorSystem.create("UserActorTest"); + Props props = Props.create(UserActor.class, out); + userActor = TestActorRef.create(system, props, "userActor"); + } + + @AfterClass + public static void teardown() { + JavaTestKit.shutdownActorSystem(system); + system = null; userActor = null; out = null; + } + + @Test + public void userActorShouldSendStockUpdate() { + running(fakeApplication(), () -> { + String symbol = "ABC"; + double price = 123; + + // send off the stock update ... + userActor.receive(new Stock.Update(symbol, price)); + + // ... and expect it to be a JSON node + assertThat(out.actual.get("type").asText()).isEqualTo("stockupdate"); + assertThat(out.actual.get("symbol").asText()).isEqualTo(symbol); + assertThat(out.actual.get("price").asDouble()).isEqualTo(price); + }); + } + + @Test + public void userActorShouldSendStockHistory() { + running(fakeApplication(), () -> { + String symbol = "ABC"; + Deque history = new LinkedList(Arrays.asList(0.1, 1.0)); + + // send off the stock history ... + userActor.receive(new Stock.History(symbol, history)); + + // ... and expect it to be a JSON node + assertThat(out.actual.get("type").asText()).isEqualTo("stockhistory"); + assertThat(out.actual.get("symbol").asText()).isEqualTo(symbol); + assertThat(out.actual.get("history").get(0).asDouble()).isEqualTo(history.getFirst()); + assertThat(out.actual.get("history").get(1).asDouble()).isEqualTo(history.getLast()); + }); + } +} From 1b874fc6abacb5b66c920c5fded92980da21cd6b Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Feb 2014 18:11:41 +1300 Subject: [PATCH 10/83] Update tutorial for new java code --- tutorial/index.html | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/tutorial/index.html b/tutorial/index.html index d2e7e2641..57f44b64e 100644 --- a/tutorial/index.html +++ b/tutorial/index.html @@ -20,7 +20,7 @@

Explore the App

Reactive Apps

- As The Reactive Manifesto outlines, Reactive apps are Resilient, Interactive, Scalable, and Event-Driven. The Reactive Stocks application has all of these characteristics because it is built using the Typesafe Platform, including Play Framework for the web interface and Akka for managing concurrency, scalability and fault-tolerance. Both Java and Scala are for the back-end code since Play and Akka support both of those languages. Any piece written in Java could have been written in Scala, or vice-versa. The front-end uses HTML, LESS (compiled to CSS), and CoffeeScript (compiled to JavaScript). WebSockets are used to push data in real-time from the server to the client.
+ As The Reactive Manifesto outlines, Reactive apps are Resilient, Interactive, Scalable, and Event-Driven. The Reactive Stocks application has all of these characteristics because it is built using the Typesafe Platform, including Play Framework for the web interface and Akka for managing concurrency, scalability and fault-tolerance. Play and Akka support both Scala and Java. This template showcases Java 8. Any piece written in Java could also have been written in Scala. The front-end uses HTML, LESS (compiled to CSS), and CoffeeScript (compiled to JavaScript). WebSockets are used to push data in real-time from the server to the client.

The Reactive Stocks application showcases four types of Reactive: Reactive Push, Reactive Requests, Reactive Composition, and Reactive UIs. Two types of Reactive which are not directly used in this application are Reactive Pull and 2-way Reactive. In a real stock feed application, the stock stream would be attached to an actual stock feed service using Reactive Pull. Those values would then be pushed to the client using Reactive Push. The combination of Reactive Push & Reactive Pull is 2-way Reactive. Play's internal network handling is 2-way Reactive.

@@ -31,11 +31,11 @@

Reactive Push

This application uses a WebSocket to push data to the browser in real-time. To create a WebSocket connection in Play, first a route must be defined in the routes file. Here is the route which will be used to setup the WebSocket connection:

GET /ws controllers.Application.ws
- The ws method in the Application.java controller handles the request and does the protocol upgrade to the WebSocket connection. This method create a new UserActor defined in UsersActor.java (tests). The UserActor stores the handle to the WebSocket connection.
+ The ws method in the Application.java controller handles the request and does the protocol upgrade to the WebSocket connection. This method create a new UserActor defined in UserActor.java (tests). The UserActor stores the handle to the WebSocket connection.

Once the UserActor is created, the default stocks (defined in application.conf) are added to the user's list of watched stocks.

- Each stock symbol has its own StockActor defined in StockActor.scala (tests). This actor holds the last 50 prices for the stock. Using a FetchHistory message the whole history can be retrieved. A FetchLatest message will generate a new price using the newPrice method in the FakeStockQuote.java file. Every StockActor sends itself a FetchLatest message every 75 milliseconds. Once a new price is generated it is added to the history and then a message is sent to each UserActor that is watching the stock. The UserActor then serializes the data as JSON and pushes it to the client using the WebSocket.
+ Each stock symbol has its own StockActor defined in StockActor.java (tests). This actor holds the last 50 prices for the stock. The initial history is sent to watchers. A Stock.Latest message will generate a new price using the newPrice method in the FakeStockQuote.java file. Every StockActor sends itself a Stock.Latest message every 75 milliseconds. Once a new price is generated it is added to the history and then a message is sent to each UserActor that is watching the stock. The UserActor then serializes the data as JSON and pushes it to the client using the WebSocket.

Underneath the covers, resources (threads) are only allocated to the Actors and WebSockets when they are needed. This is why Reactive Push is scalable with Play and Akka.

@@ -50,7 +50,7 @@

Reactive UI - Real-time Chart

$ ->
   ws = new WebSocket $("body").data("ws-url")
   ws.onmessage = (event) ->
-    message = JSON.parse event.data
+ message = JSON.parse event.data The message is parsed and depending on whether the message contains the stock history or a stock update, a stock chart is either created or updated. The charts are created using the Flot JavaScript charting library. Using CoffeeScript, jQuery, and Flot makes it easy to build Reactive UI in the browser that can receive WebSocket push events and update the UI in real-time.

@@ -61,9 +61,9 @@

Reactive Requests

When a web server gets a request, it allocates a thread to handle the request and produce a response. In a typical model the thread is allocated for the entire duration of the request and response, even if the web request is waiting for some other resource. A Reactive Request is a typical web request and response, but handled in an asynchronous and non-blocking way on the server. This means that when the thread for a web request is not actively being used, it can be released and reused for something else.
In the Reactive Stocks application the service which determines the stock sentiments is a Reactive Request. The route is defined in the routes file:
GET /sentiment/:symbol controllers.StockSentiment.get(symbol)
- A GET request to /sentiment/GOOG will call get("GOOG") on the StockSentiment.scala controller. That method begins with: -
def get(symbol: String): Action[AnyContent] = Action.async {
- The async block indicates that the controller will return a Future[Result] which is a handle to something that will produce a Result in the future. The Future provides a way to do asynchronous handling but doesn't necessarily have to be non-blocking. Often times web requests need to talk to other systems (databases, web services, etc). If a thread can't be deallocated while waiting for those other systems to respond, then it is blocking.
+ A GET request to /sentiment/GOOG will call get("GOOG") on the StockSentiment.java controller. That method begins with: +
public static Promise<Result> get(String symbol) {
+ This controller will return a Promise which is a handle to something that will produce a Result in the future. The Promise provides a way to do asynchronous handling but doesn't necessarily have to be non-blocking. Often times web requests need to talk to other systems (databases, web services, etc). If a thread can't be deallocated while waiting for those other systems to respond, then it is blocking.
In this case a request is made to Twitter and then for each tweet, another request is made to a sentiment service. All of these requests, including the request from the browser, are all handled as Reactive Requests so that the entire pipeline is Reactive (asynchronous and non-blocking). This is called Reactive Composition.

@@ -71,11 +71,10 @@

Reactive Requests

Reactive Composition

- Combining multiple Reactive Requests together is Reactive Composition. The StockSentiment controller does Reactive Composition since it receives a request, makes a request to Twitter for tweets about a stock, and then for each tweet it makes a request to a sentiment service. All of these requests are Reactive Requests. None use threads when they are waiting for a response. Scala's for comprehensions make it very easy and elegant to do Reactive Composition. The basic structure is: -

for {
-  tweets <- tweetsFuture
-  sentiments <- Future.sequence(futuresForTweetSentiment(tweets))
-} yield Ok(sentiments)
+ Combining multiple Reactive Requests together is Reactive Composition. The StockSentiment controller does Reactive Composition since it receives a request, makes a request to Twitter for tweets about a stock, and then for each tweet it makes a request to a sentiment service. All of these requests are Reactive Requests. None use threads when they are waiting for a response. Play Promises, together with Java 8 lambdas and method references, make it very easy and elegant to do Reactive Composition. The basic structure is: +
fetchTweets(symbol)
+.flatMap(StockSentiment::fetchSentiments)
+.map(StockSentiment::averageSentiment)
Because the web client library in Play, WS, is asynchronous and non-blocking, all of the requests needed to get a stock's sentiments are Reactive Requests. Combined together these Reactive Requests are Reactive Composition.

@@ -87,16 +86,11 @@

Reactive UI - Sentiments

-

Typesafe Console

+

Inspect the App

- The Typesafe Console visualizes the internals of Play Framework and Akka applications in real-time. To enable the Console you will need a free Typesafe.com account since the Console is licensed under the Typesafe Subscription Agreement which allows it to be used at development time for free. Production use requires a Typesafe Subscription. + Use Inspect to see the requests that have been handled by the running Play application and to see the Akka Actors (if any) in the Actor System. Drilling down into an individual request will provide details about the request. Drilling down into an individual Actor will display a number of statistics and information about the Actor. Deviations will show you any issues with your Actors.

- -

- To enable the Console, in Run click Login to Typesafe.com and login. If you don't have an account, then sign up and then login inside Activator (click the person icon in the top-right to open the login form). Once logged in, click the Restart with Console button to start the selected application with Console support. There will then be a link to the Console UI. Open that link to enter the Typesafe Console. Learn more about the Typesafe Console. -

-

Further Learning

From 996991db80dce2a7ab6e351656f6d6721aa15d9a Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Mon, 10 Mar 2014 11:04:53 +1300 Subject: [PATCH 11/83] Update name, title, and description --- LICENSE | 2 +- activator.properties | 8 ++++---- build.sbt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/LICENSE b/LICENSE index a02154466..6c42406c7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2013 Typesafe, Inc. +Copyright 2014 Typesafe, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/activator.properties b/activator.properties index 1c1e66555..8dc5ad3af 100644 --- a/activator.properties +++ b/activator.properties @@ -1,4 +1,4 @@ -name=reactive-stocks -title=Reactive Stocks -description=The Reactive Stocks application uses Java, Scala, Play Framework, and Akka to illustrate a reactive app. The tutorial in this example will teach you the reactive basics including Reactive Composition and Reactive Push. -tags=Sample,java,scala,playframework,akka,reactive +name=reactive-stocks-java8 +title=Reactive Stocks (Java 8) +description=The Reactive Stocks application uses Java, Play Framework, and Akka to illustrate a reactive app. The tutorial in this example will teach you the reactive basics including Reactive Composition and Reactive Push. +tags=Sample,java,java8,playframework,akka,reactive diff --git a/build.sbt b/build.sbt index 552c330b7..7f82e23a8 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -name := "reactive-stocks" +name := "reactive-stocks-java8" version := "1.0-SNAPSHOT" From 823aee97af8fc182e2d4b2191241ae605feea21b Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Tue, 11 Mar 2014 17:54:43 +1300 Subject: [PATCH 12/83] Update to akka 2.3.0 final --- app/utils/LambdaActor.java | 4 ---- build.sbt | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/utils/LambdaActor.java b/app/utils/LambdaActor.java index 4f3d20c59..83a0f68c5 100644 --- a/app/utils/LambdaActor.java +++ b/app/utils/LambdaActor.java @@ -23,8 +23,4 @@ public void receive(final Class type, FI.UnitApply apply) { public PartialFunction receive() { return receiveBuilder.build(); } - - public UntypedActorContext getContext() { - return (UntypedActorContext) context(); - } } diff --git a/build.sbt b/build.sbt index 7f82e23a8..1675c3d5d 100644 --- a/build.sbt +++ b/build.sbt @@ -4,12 +4,12 @@ version := "1.0-SNAPSHOT" libraryDependencies ++= Seq( javaWs, - //"com.typesafe.akka" %% "akka-actor" % "2.3.0-RC1", - //"com.typesafe.akka" %% "akka-slf4j" % "2.3.0-RC1", + //"com.typesafe.akka" %% "akka-actor" % "2.3.0", + //"com.typesafe.akka" %% "akka-slf4j" % "2.3.0", "org.webjars" %% "webjars-play" % "2.3-SNAPSHOT", "org.webjars" % "bootstrap" % "2.3.1", "org.webjars" % "flot" % "0.8.0", - "com.typesafe.akka" %% "akka-testkit" % "2.3.0-RC1" % "test" + "com.typesafe.akka" %% "akka-testkit" % "2.3.0" % "test" ) play.Project.playJavaSettings From 79a7b680fd0232ad55f61aa3ca5b2a6b75b31125 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 12 Mar 2014 09:57:03 +1300 Subject: [PATCH 13/83] Remove comment about java actor context --- app/utils/LambdaActor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/LambdaActor.java b/app/utils/LambdaActor.java index 83a0f68c5..95650c25d 100644 --- a/app/utils/LambdaActor.java +++ b/app/utils/LambdaActor.java @@ -10,7 +10,7 @@ /** * An actor that allows receive builder matches to be specified in the constructor, - * avoiding boilerplate and Scala types. Also provides the Java actor context. + * avoiding boilerplate and Scala types. */ public class LambdaActor extends AbstractActor { From ac6256bde791f81c085e67657411ef8ea60ed57f Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Thu, 13 Mar 2014 11:01:17 +1300 Subject: [PATCH 14/83] Use new WebSocket.whenReady method --- app/controllers/Application.java | 42 +++++++++++++++----------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/app/controllers/Application.java b/app/controllers/Application.java index d68ea071b..e800deeeb 100644 --- a/app/controllers/Application.java +++ b/app/controllers/Application.java @@ -23,30 +23,28 @@ public static Result index() { } public static WebSocket ws() { - return new WebSocket() { - public void onReady(final WebSocket.In in, final WebSocket.Out out) { - // create a new UserActor and give it the default stocks to watch - final ActorRef userActor = Akka.system().actorOf(Props.create(UserActor.class, out)); - List defaultStocks = Play.application().configuration().getStringList("default.stocks"); - for (String stockSymbol : defaultStocks) { - StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), userActor); - } + return WebSocket.whenReady((in, out) -> { + // create a new UserActor and give it the default stocks to watch + final ActorRef userActor = Akka.system().actorOf(Props.create(UserActor.class, out)); + List defaultStocks = Play.application().configuration().getStringList("default.stocks"); + for (String stockSymbol : defaultStocks) { + StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), userActor); + } - // send all WebSocket message to the UserActor - in.onMessage(jsonNode -> { - // parse the JSON into Stock.Watch - Stock.Watch watchStock = new Stock.Watch(jsonNode.get("symbol").textValue()); - // send the watchStock message to the StocksActor - StocksActor.stocksActor().tell(watchStock, userActor); - }); + // send all WebSocket message to the UserActor + in.onMessage(jsonNode -> { + // parse the JSON into Stock.Watch + Stock.Watch watchStock = new Stock.Watch(jsonNode.get("symbol").textValue()); + // send the watchStock message to the StocksActor + StocksActor.stocksActor().tell(watchStock, userActor); + }); - // on close, tell the userActor to shutdown - in.onClose(() -> { - StocksActor.stocksActor().tell(new Stock.Unwatch(Optional.empty()), userActor); - Akka.system().stop(userActor); - }); - } - }; + // on close, tell the userActor to shutdown + in.onClose(() -> { + StocksActor.stocksActor().tell(new Stock.Unwatch(Optional.empty()), userActor); + Akka.system().stop(userActor); + }); + }); } } From 95138faf6b01ce04e5eef7d937a7c49d5cb74514 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Thu, 13 Mar 2014 11:27:43 +1300 Subject: [PATCH 15/83] Remove inspect from the tutorial Add it again when there's echo support for play 2.3 --- tutorial/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tutorial/index.html b/tutorial/index.html index 57f44b64e..79ae3d068 100644 --- a/tutorial/index.html +++ b/tutorial/index.html @@ -50,7 +50,7 @@

Reactive UI - Real-time Chart

$ ->
   ws = new WebSocket $("body").data("ws-url")
   ws.onmessage = (event) ->
-  message = JSON.parse event.data
+ message = JSON.parse event.data The message is parsed and depending on whether the message contains the stock history or a stock update, a stock chart is either created or updated. The charts are created using the Flot JavaScript charting library. Using CoffeeScript, jQuery, and Flot makes it easy to build Reactive UI in the browser that can receive WebSocket push events and update the UI in real-time.

@@ -85,13 +85,13 @@

Reactive UI - Sentiments

The client-side of Reactive Requests and Reactive Composition is no different than the non-Reactive model. The browser makes an Ajax request to the server and then calls a JavaScript function when it receives a response. In the Reactive Stocks application, when a stock chart is flipped over it makes the request for the stock's sentiments. That is done using jQuery's ajax method in the
index.coffee file. When the request returns data the success handler updates the UI.

-
+

Further Learning

From 9db9b63dd765d9962e9178ea57ccb0e0a901e41c Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Thu, 13 Mar 2014 12:02:33 +1300 Subject: [PATCH 16/83] Use published play snapshots --- build.sbt | 6 +++--- project/plugins.sbt | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/build.sbt b/build.sbt index 1675c3d5d..a30967820 100644 --- a/build.sbt +++ b/build.sbt @@ -2,11 +2,11 @@ name := "reactive-stocks-java8" version := "1.0-SNAPSHOT" +resolvers += Resolver.typesafeRepo("snapshots") + libraryDependencies ++= Seq( javaWs, - //"com.typesafe.akka" %% "akka-actor" % "2.3.0", - //"com.typesafe.akka" %% "akka-slf4j" % "2.3.0", - "org.webjars" %% "webjars-play" % "2.3-SNAPSHOT", + "org.webjars" %% "webjars-play" % playVersion.value, "org.webjars" % "bootstrap" % "2.3.1", "org.webjars" % "flot" % "0.8.0", "com.typesafe.akka" %% "akka-testkit" % "2.3.0" % "test" diff --git a/project/plugins.sbt b/project/plugins.sbt index 8bfc31346..11ccfc093 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,8 +1,7 @@ -// Comment to get more information during initialization -logLevel := Level.Warn +resolvers ++= Seq( + Resolver.typesafeRepo("releases"), + Resolver.typesafeRepo("snapshots"), + Resolver.typesafeIvyRepo("snapshots") +) -// The Typesafe repository -resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" - -// Use the Play sbt plugin for Play projects -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-SNAPSHOT") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-2014-03-12-237b90f-SNAPSHOT") From 170c09461d352d0aaa5b1bc56fe9c65a2042ea79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjo=CC=88rn=20Antonsson?= Date: Mon, 24 Mar 2014 22:09:13 +0100 Subject: [PATCH 17/83] Updating template for Akka 2.3.1 --- app/actors/StockActor.java | 48 ++++++++++++++++++------------------ app/actors/StocksActor.java | 37 ++++++++++++++-------------- app/actors/UserActor.java | 49 +++++++++++++++++++------------------ app/utils/LambdaActor.java | 26 -------------------- build.sbt | 2 +- project/plugins.sbt | 2 +- 6 files changed, 70 insertions(+), 94 deletions(-) delete mode 100644 app/utils/LambdaActor.java diff --git a/app/actors/StockActor.java b/app/actors/StockActor.java index 8aea1e144..89544475d 100644 --- a/app/actors/StockActor.java +++ b/app/actors/StockActor.java @@ -1,21 +1,22 @@ package actors; +import akka.actor.AbstractActor; import akka.actor.ActorRef; import akka.actor.Cancellable; +import akka.japi.pf.ReceiveBuilder; import java.util.concurrent.TimeUnit; import java.util.Deque; import java.util.HashSet; import java.util.Optional; import scala.concurrent.duration.Duration; import utils.FakeStockQuote; -import utils.LambdaActor; import utils.StockQuote; /** * There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock * values. Each StockActor updates a rolling dataset of randomly generated stock values. */ -public class StockActor extends LambdaActor { +public class StockActor extends AbstractActor { final HashSet watchers = new HashSet(); @@ -28,28 +29,27 @@ public StockActor(String symbol) { public StockActor(String symbol, StockQuote stockQuote, boolean tick) { Optional stockTick = tick ? Optional.of(scheduleTick()) : Optional.empty(); - receive(Stock.Latest.class, latest -> { - // add a new stock price to the history and drop the oldest - Double newPrice = stockQuote.newPrice(stockHistory.peekLast()); - stockHistory.add(newPrice); - stockHistory.remove(); - // notify watchers - watchers.forEach(watcher -> watcher.tell(new Stock.Update(symbol, newPrice), self())); - }); - - receive(Stock.Watch.class, watch -> { - // reply with the stock history, and add the sender as a watcher - sender().tell(new Stock.History(symbol, stockHistory), self()); - watchers.add(sender()); - }); - - receive(Stock.Unwatch.class, unwatch -> { - watchers.remove(sender()); - if (watchers.isEmpty()) { - stockTick.ifPresent(Cancellable::cancel); - context().stop(self()); - } - }); + receive(ReceiveBuilder + .match(Stock.Latest.class, latest -> { + // add a new stock price to the history and drop the oldest + Double newPrice = stockQuote.newPrice(stockHistory.peekLast()); + stockHistory.add(newPrice); + stockHistory.remove(); + // notify watchers + watchers.forEach(watcher -> watcher.tell(new Stock.Update(symbol, newPrice), self())); + }) + .match(Stock.Watch.class, watch -> { + // reply with the stock history, and add the sender as a watcher + sender().tell(new Stock.History(symbol, stockHistory), self()); + watchers.add(sender()); + }) + .match(Stock.Unwatch.class, unwatch -> { + watchers.remove(sender()); + if (watchers.isEmpty()) { + stockTick.ifPresent(Cancellable::cancel); + context().stop(self()); + } + }).build()); } private Cancellable scheduleTick() { diff --git a/app/actors/StocksActor.java b/app/actors/StocksActor.java index f727eb7dd..9abf55124 100644 --- a/app/actors/StocksActor.java +++ b/app/actors/StocksActor.java @@ -1,13 +1,14 @@ package actors; +import akka.actor.AbstractActor; import akka.actor.ActorRef; import akka.actor.Props; +import akka.japi.pf.ReceiveBuilder; import java.util.Collections; import java.util.Optional; import play.libs.Akka; -import utils.LambdaActor; -public class StocksActor extends LambdaActor { +public class StocksActor extends AbstractActor { private static class LazyStocksActor { public static final ActorRef ref = Akka.system().actorOf(Props.create(StocksActor.class)); @@ -18,21 +19,21 @@ public static ActorRef stocksActor() { } public StocksActor() { - receive(Stock.Watch.class, watch -> { - String symbol = watch.symbol; - // get or create the StockActor for the symbol and forward this message - Optional.ofNullable(getContext().getChild(symbol)).orElseGet( - () -> context().actorOf(Props.create(StockActor.class, symbol), symbol) - ).forward(watch, context()); - }); - - receive(Stock.Unwatch.class, unwatch -> { - // forward this message to the associated StockActor, or otherwise to everyone - unwatch.symbol - .map(getContext()::getChild) - .>map(Collections::singletonList) - .orElse(getContext().getChildren()) - .forEach(child -> child.forward(unwatch, context())); - }); + receive(ReceiveBuilder + .match(Stock.Watch.class, watch -> { + String symbol = watch.symbol; + // get or create the StockActor for the symbol and forward this message + Optional.ofNullable(getContext().getChild(symbol)).orElseGet( + () -> context().actorOf(Props.create(StockActor.class, symbol), symbol) + ).forward(watch, context()); + }) + .match(Stock.Unwatch.class, unwatch -> { + // forward this message to the associated StockActor, or otherwise to everyone + unwatch.symbol + .map(getContext()::getChild) + .>map(Collections::singletonList) + .orElse(getContext().getChildren()) + .forEach(child -> child.forward(unwatch, context())); + }).build()); } } diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java index f5b01cd65..d94f6ab8d 100644 --- a/app/actors/UserActor.java +++ b/app/actors/UserActor.java @@ -1,42 +1,43 @@ package actors; +import akka.actor.AbstractActor; +import akka.japi.pf.ReceiveBuilder; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import play.libs.Json; import play.mvc.WebSocket; -import utils.LambdaActor; /** * The broker between the WebSocket and the StockActor(s). The UserActor holds the connection and sends serialized * JSON data to the client. */ -public class UserActor extends LambdaActor { +public class UserActor extends AbstractActor { public UserActor(WebSocket.Out out) { - receive(Stock.Update.class, stockUpdate -> { - // push the stock to the client - JsonNode message = - Json.newObject() - .put("type", "stockupdate") - .put("symbol", stockUpdate.symbol) - .put("price", stockUpdate.price); - out.write(message); - }); + receive(ReceiveBuilder + .match(Stock.Update.class, stockUpdate -> { + // push the stock to the client + JsonNode message = + Json.newObject() + .put("type", "stockupdate") + .put("symbol", stockUpdate.symbol) + .put("price", stockUpdate.price); + out.write(message); + }) + .match(Stock.History.class, stockHistory -> { + // push the history to the client + ObjectNode message = + Json.newObject() + .put("type", "stockhistory") + .put("symbol", stockHistory.symbol); - receive(Stock.History.class, stockHistory -> { - // push the history to the client - ObjectNode message = - Json.newObject() - .put("type", "stockhistory") - .put("symbol", stockHistory.symbol); + ArrayNode historyJson = message.putArray("history"); + for (Double price : stockHistory.history) { + historyJson.add(price); + } - ArrayNode historyJson = message.putArray("history"); - for (Double price : stockHistory.history) { - historyJson.add(price); - } - - out.write(message); - }); + out.write(message); + }).build()); } } diff --git a/app/utils/LambdaActor.java b/app/utils/LambdaActor.java deleted file mode 100644 index 95650c25d..000000000 --- a/app/utils/LambdaActor.java +++ /dev/null @@ -1,26 +0,0 @@ -package utils; - -import akka.actor.AbstractActor; -import akka.actor.UntypedActorContext; -import akka.japi.pf.FI; -import akka.japi.pf.ReceiveBuilder; -import akka.japi.pf.UnitPFBuilder; -import scala.PartialFunction; -import scala.runtime.BoxedUnit; - -/** - * An actor that allows receive builder matches to be specified in the constructor, - * avoiding boilerplate and Scala types. - */ -public class LambdaActor extends AbstractActor { - - private UnitPFBuilder receiveBuilder = new UnitPFBuilder(); - - public void receive(final Class type, FI.UnitApply apply) { - receiveBuilder = receiveBuilder.match(type, apply); - } - - public PartialFunction receive() { - return receiveBuilder.build(); - } -} diff --git a/build.sbt b/build.sbt index a30967820..8cfb6b16f 100644 --- a/build.sbt +++ b/build.sbt @@ -9,7 +9,7 @@ libraryDependencies ++= Seq( "org.webjars" %% "webjars-play" % playVersion.value, "org.webjars" % "bootstrap" % "2.3.1", "org.webjars" % "flot" % "0.8.0", - "com.typesafe.akka" %% "akka-testkit" % "2.3.0" % "test" + "com.typesafe.akka" %% "akka-testkit" % "2.3.1" % "test" ) play.Project.playJavaSettings diff --git a/project/plugins.sbt b/project/plugins.sbt index 11ccfc093..5a08b53c0 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,4 +4,4 @@ resolvers ++= Seq( Resolver.typesafeIvyRepo("snapshots") ) -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-2014-03-12-237b90f-SNAPSHOT") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-SNAPSHOT") From ae04e0b5ed3ff996574c250a7734335bac2ead22 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Mar 2014 13:09:09 +1300 Subject: [PATCH 18/83] Fix typo: determing -> determining --- app/assets/javascripts/index.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/index.coffee b/app/assets/javascripts/index.coffee index b5fd83049..3a84024d7 100644 --- a/app/assets/javascripts/index.coffee +++ b/app/assets/javascripts/index.coffee @@ -96,5 +96,5 @@ handleFlip = (container) -> detailsHolder.append($("

").text("Error: " + JSON.parse(jqXHR.responseText).error)) # display loading info detailsHolder = container.find(".details-holder") - detailsHolder.append($("

").text("Determing whether you should buy or sell based on the sentiment of recent tweets...")) + detailsHolder.append($("

").text("Determining whether you should buy or sell based on the sentiment of recent tweets...")) detailsHolder.append($("
").addClass("progress progress-striped active").append($("
").addClass("bar").css("width", "100%"))) \ No newline at end of file From f2f46e79c5b9975458c0de8df690c6b1bcc39e77 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Wed, 26 Mar 2014 13:16:39 +1300 Subject: [PATCH 19/83] Use latest stamped play snapshot (with akka 2.3.1) --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 5a08b53c0..d2f8840a2 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,4 +4,4 @@ resolvers ++= Seq( Resolver.typesafeIvyRepo("snapshots") ) -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-SNAPSHOT") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-2014-03-25-58325fd-SNAPSHOT") From 649fac313f847acb422f0cf4a26c240291b723be Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Fri, 11 Apr 2014 11:36:55 +1200 Subject: [PATCH 20/83] Update to play 2.3-M1 --- app/views/index.scala.html | 10 +++++----- build.sbt | 12 +++++++----- conf/routes | 1 - project/build.properties | 2 +- project/plugins.sbt | 12 ++++++------ 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/app/views/index.scala.html b/app/views/index.scala.html index d1b0995b8..66f4bdb63 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -5,12 +5,12 @@ Reactive Stock News Dashboard - + - - - + + +
- +
\ No newline at end of file diff --git a/build.sbt b/build.sbt index 8cfb6b16f..935ea8b5b 100644 --- a/build.sbt +++ b/build.sbt @@ -1,21 +1,23 @@ +import play._ +import play.Keys._ + +lazy val root = (project in file(".")).addPlugins(PlayScala) + name := "reactive-stocks-java8" version := "1.0-SNAPSHOT" -resolvers += Resolver.typesafeRepo("snapshots") - libraryDependencies ++= Seq( javaWs, - "org.webjars" %% "webjars-play" % playVersion.value, "org.webjars" % "bootstrap" % "2.3.1", "org.webjars" % "flot" % "0.8.0", "com.typesafe.akka" %% "akka-testkit" % "2.3.1" % "test" ) -play.Project.playJavaSettings - javacOptions ++= Seq("-source", "1.8", "-target", "1.8", "-Xlint") +LessKeys.compress := true + initialize := { val _ = initialize.value if (sys.props("java.specification.version") != "1.8") diff --git a/conf/routes b/conf/routes index 731bf1a07..b045fbb89 100644 --- a/conf/routes +++ b/conf/routes @@ -8,4 +8,3 @@ GET /sentiment/:symbol controllers.StockSentiment.get(symbol) # Map static resources from the /public folder to the /assets URL path GET /assets/*file controllers.Assets.at(path="/public", file) -GET /webjars/*file controllers.WebJarAssets.at(file) \ No newline at end of file diff --git a/project/build.properties b/project/build.properties index 37b489cb6..778915952 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.1 +sbt.version=0.13.5-M3 diff --git a/project/plugins.sbt b/project/plugins.sbt index d2f8840a2..33e1a2ddc 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,7 @@ -resolvers ++= Seq( - Resolver.typesafeRepo("releases"), - Resolver.typesafeRepo("snapshots"), - Resolver.typesafeIvyRepo("snapshots") -) +resolvers += Resolver.typesafeRepo("releases") -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-2014-03-25-58325fd-SNAPSHOT") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-M1") + +addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0-M2a") + +addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0-M2a") From a6a09c8949a88c78daabdf0fcd7bbc8a7afd6531 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Thu, 8 May 2014 14:52:49 +1200 Subject: [PATCH 21/83] Update to play 2.3.0-RC1 --- build.sbt | 5 +---- project/build.properties | 2 +- project/plugins.sbt | 6 +++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/build.sbt b/build.sbt index 935ea8b5b..10d9f81eb 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,4 @@ -import play._ -import play.Keys._ - -lazy val root = (project in file(".")).addPlugins(PlayScala) +lazy val root = (project in file(".")).enablePlugins(PlayScala) name := "reactive-stocks-java8" diff --git a/project/build.properties b/project/build.properties index 778915952..ea7ce39dd 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.5-M3 +sbt.version=0.13.5-RC1 diff --git a/project/plugins.sbt b/project/plugins.sbt index 33e1a2ddc..596ebf5d5 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,7 @@ resolvers += Resolver.typesafeRepo("releases") -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3-M1") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3.0-RC1") -addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0-M2a") +addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0-RC1") -addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0-M2a") +addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0-RC1") From 4fe57475652a7deb99a3d00cec22fa5823adcb16 Mon Sep 17 00:00:00 2001 From: Peter Vlugter Date: Sat, 31 May 2014 13:29:05 +1200 Subject: [PATCH 22/83] Update to play 2.3.0 --- build.sbt | 2 +- project/build.properties | 2 +- project/plugins.sbt | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.sbt b/build.sbt index 10d9f81eb..31fcbd8a9 100644 --- a/build.sbt +++ b/build.sbt @@ -8,7 +8,7 @@ libraryDependencies ++= Seq( javaWs, "org.webjars" % "bootstrap" % "2.3.1", "org.webjars" % "flot" % "0.8.0", - "com.typesafe.akka" %% "akka-testkit" % "2.3.1" % "test" + "com.typesafe.akka" %% "akka-testkit" % "2.3.3" % "test" ) javacOptions ++= Seq("-source", "1.8", "-target", "1.8", "-Xlint") diff --git a/project/build.properties b/project/build.properties index ea7ce39dd..be6c454fb 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.5-RC1 +sbt.version=0.13.5 diff --git a/project/plugins.sbt b/project/plugins.sbt index 596ebf5d5..8792a6392 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,7 @@ resolvers += Resolver.typesafeRepo("releases") -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3.0-RC1") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3.0") -addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0-RC1") +addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0") -addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0-RC1") +addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0") From 7db01a99a28a4f411105154e9429766999a0fd7d Mon Sep 17 00:00:00 2001 From: Kip Sigman Date: Mon, 18 Aug 2014 17:55:59 -0700 Subject: [PATCH 23/83] updated play version --- build.sbt | 2 ++ project/plugins.sbt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 31fcbd8a9..a1e6fd910 100644 --- a/build.sbt +++ b/build.sbt @@ -4,6 +4,8 @@ name := "reactive-stocks-java8" version := "1.0-SNAPSHOT" +scalaVersion := "2.11.2" + libraryDependencies ++= Seq( javaWs, "org.webjars" % "bootstrap" % "2.3.1", diff --git a/project/plugins.sbt b/project/plugins.sbt index 8792a6392..a53684e08 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,6 +1,6 @@ resolvers += Resolver.typesafeRepo("releases") -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3.0") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3.3") addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0") From 81a3c5bb1a4bd9c68fefd291e6a593ab07f8fc6d Mon Sep 17 00:00:00 2001 From: retroryan Date: Sun, 26 Oct 2014 06:43:16 -0500 Subject: [PATCH 24/83] adding fix to main.less --- app/assets/stylesheets/main.less | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/main.less b/app/assets/stylesheets/main.less index 667977d9c..a211787d6 100644 --- a/app/assets/stylesheets/main.less +++ b/app/assets/stylesheets/main.less @@ -79,6 +79,10 @@ body { backface-visibility: hidden; } +.details-holder { + transform-style: preserve-3d; +} + .chart-holder { z-index: 2; & p { From d236db3c1d374874d1f0830d74a3483a3fdcd300 Mon Sep 17 00:00:00 2001 From: Markus Jura Date: Sun, 7 Jun 2015 16:44:41 +0200 Subject: [PATCH 25/83] Bump to Play 2.4.0 --- app/controllers/Application.java | 5 ++--- app/controllers/StockSentiment.java | 15 +++++++------- build.sbt | 15 ++++++++------ conf/application.conf | 17 ++------------- conf/logback.xml | 32 +++++++++++++++++++++++++++++ project/build.properties | 2 +- project/plugins.sbt | 4 ++-- 7 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 conf/logback.xml diff --git a/app/controllers/Application.java b/app/controllers/Application.java index e800deeeb..b63f84bfc 100644 --- a/app/controllers/Application.java +++ b/app/controllers/Application.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Optional; import play.libs.Akka; -import play.libs.F; import play.mvc.Controller; import play.mvc.Result; import play.mvc.WebSocket; @@ -18,11 +17,11 @@ */ public class Application extends Controller { - public static Result index() { + public Result index() { return ok(views.html.index.render()); } - public static WebSocket ws() { + public WebSocket ws() { return WebSocket.whenReady((in, out) -> { // create a new UserActor and give it the default stocks to watch final ActorRef userActor = Akka.system().actorOf(Props.create(UserActor.class, out)); diff --git a/app/controllers/StockSentiment.java b/app/controllers/StockSentiment.java index 4e46fa783..c1d25c70a 100644 --- a/app/controllers/StockSentiment.java +++ b/app/controllers/StockSentiment.java @@ -8,7 +8,6 @@ import play.libs.ws.WSResponse; import play.mvc.*; import play.Play; - import static java.util.stream.Collectors.averagingDouble; import static java.util.stream.Collectors.toList; import static play.libs.F.Promise; @@ -16,7 +15,7 @@ public class StockSentiment extends Controller { - public static Promise get(String symbol) { + public Promise get(String symbol) { return fetchTweets(symbol) .flatMap(StockSentiment::fetchSentiments) .map(StockSentiment::averageSentiment) @@ -24,7 +23,7 @@ public static Promise get(String symbol) { .recover(StockSentiment::errorResponse); } - public static Promise> fetchTweets(String symbol) { + private static Promise> fetchTweets(String symbol) { return WS.url(Play.application().configuration().getString("tweet.url")) .setQueryParameter("q", "$" + symbol).get() .filter(response -> response.getStatus() == Http.Status.OK) @@ -33,17 +32,17 @@ public static Promise> fetchTweets(String symbol) { .collect(toList())); } - public static Promise> fetchSentiments(List tweets) { + private static Promise> fetchSentiments(List tweets) { String url = Play.application().configuration().getString("sentiment.url"); Stream> sentiments = tweets.stream().map(text -> WS.url(url).post("text=" + text)); return Promise.sequence(sentiments::iterator).map(StockSentiment::responsesAsJson); } - public static List responsesAsJson(List responses) { + private static List responsesAsJson(List responses) { return responses.stream().map(WSResponse::asJson).collect(toList()); } - public static JsonNode averageSentiment(List sentiments) { + private static JsonNode averageSentiment(List sentiments) { double neg = collectAverage(sentiments, "neg"); double neutral = collectAverage(sentiments, "neutral"); double pos = collectAverage(sentiments, "pos"); @@ -58,11 +57,11 @@ public static JsonNode averageSentiment(List sentiments) { .put("pos", pos)); } - public static double collectAverage(List jsons, String label) { + private static double collectAverage(List jsons, String label) { return jsons.stream().collect(averagingDouble(json -> json.findValue(label).asDouble())); } - public static Result errorResponse(Throwable ignored) { + private static Result errorResponse(Throwable ignored) { return internalServerError(Json.newObject().put("error", "Could not fetch the tweets")); } } diff --git a/build.sbt b/build.sbt index a1e6fd910..761e0b86d 100644 --- a/build.sbt +++ b/build.sbt @@ -1,16 +1,17 @@ -lazy val root = (project in file(".")).enablePlugins(PlayScala) - name := "reactive-stocks-java8" -version := "1.0-SNAPSHOT" +version := "1.0" + +scalaVersion := "2.11.6" -scalaVersion := "2.11.2" +lazy val root = (project in file(".")).enablePlugins(PlayJava) libraryDependencies ++= Seq( javaWs, - "org.webjars" % "bootstrap" % "2.3.1", + "org.webjars" % "bootstrap" % "2.3.2", "org.webjars" % "flot" % "0.8.0", - "com.typesafe.akka" %% "akka-testkit" % "2.3.3" % "test" + "org.easytesting" % "fest-assert" % "1.4" % Test, + "com.typesafe.akka" %% "akka-testkit" % "2.3.11" % Test ) javacOptions ++= Seq("-source", "1.8", "-target", "1.8", "-Xlint") @@ -22,3 +23,5 @@ initialize := { if (sys.props("java.specification.version") != "1.8") sys.error("Java 8 is required for this project.") } + +routesGenerator := InjectedRoutesGenerator \ No newline at end of file diff --git a/conf/application.conf b/conf/application.conf index 98bc5b5b0..97f3ae225 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -5,11 +5,11 @@ # ~~~~~ # The secret key is used to secure cryptographics functions. # If you deploy your application to several instances be sure to use the same key! -application.secret="Yf]0bsdO2ckhJd]^sQ^IPISElBrfyCn;nnaX:N/=R1<" +play.crypto.secret="Yf]0bsdO2ckhJd]^sQ^IPISElBrfyCn;nnaX:N/=R1<" # The application languages # ~~~~~ -application.langs="en" +play.i18n.langs="en" # Global object class # ~~~~~ @@ -43,19 +43,6 @@ application.langs="en" # You can disable evolutions if needed # evolutionplugin=disabled -# Logger -# ~~~~~ -# You can also configure logback (http://logback.qos.ch/), by providing a logger.xml file in the conf directory . - -# Root logger: -logger.root=ERROR - -# Logger used by the framework: -logger.play=INFO - -# Logger provided to your application: -logger.application=DEBUG - # Uncomment this for the most verbose Akka debugging: #akka { # loglevel = "DEBUG" diff --git a/conf/logback.xml b/conf/logback.xml new file mode 100644 index 000000000..d62e80859 --- /dev/null +++ b/conf/logback.xml @@ -0,0 +1,32 @@ + + + + + + logs/application.log + + %date [%level] from %logger in %thread - %message%n%xException + + + + + + %coloredLevel %logger{15} - %message%n%xException{10} + + + + + + + + + + + + + + + + + + diff --git a/project/build.properties b/project/build.properties index be6c454fb..a6e117b61 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.5 +sbt.version=0.13.8 diff --git a/project/plugins.sbt b/project/plugins.sbt index a53684e08..d02f89ac7 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,7 @@ resolvers += Resolver.typesafeRepo("releases") -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3.3") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.4.0") -addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0") +addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.0") addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0") From 83432a3c80e0bec4b712953c7df7e5ec3a1ee85e Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Wed, 11 May 2016 00:32:25 -0400 Subject: [PATCH 26/83] Upgrade to 2.5.3 and akka streams. --- README.md | 13 +++ app/Module.java | 13 +++ app/actors/StockActor.java | 8 +- app/actors/StocksActor.java | 58 ++++----- app/actors/UserActor.java | 108 +++++++++++++---- app/actors/UserParentActor.java | 37 ++++++ app/controllers/Application.java | 49 -------- app/controllers/HomeController.java | 143 +++++++++++++++++++++++ app/controllers/StockSentiment.java | 100 +++++++++++----- app/views/index.scala.html | 6 +- build.sbt | 16 +-- conf/application.conf | 36 +----- conf/logback.xml | 2 +- conf/routes | 4 +- project/build.properties | 2 +- project/plugins.sbt | 3 +- test/actors/StockActorTest.java | 2 +- test/actors/StubOut.java | 18 --- test/actors/UserActorTest.java | 57 ++++++--- test/controllers/HomeControllerTest.java | 85 ++++++++++++++ tutorial/index.html | 2 +- 21 files changed, 538 insertions(+), 224 deletions(-) create mode 100644 README.md create mode 100644 app/Module.java create mode 100644 app/actors/UserParentActor.java delete mode 100644 app/controllers/Application.java create mode 100644 app/controllers/HomeController.java delete mode 100644 test/actors/StubOut.java create mode 100644 test/controllers/HomeControllerTest.java diff --git a/README.md b/README.md new file mode 100644 index 000000000..d95198bf8 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# reactive-stocks + +This is an example Play application that shows how to use Play's Websocket API in Java, by showing a series of stock tickers updated using WebSocket. + +The Websocket API is built on Akka Streams, and so is async, non-blocking, and backpressure aware. Using Akka Streams also means that interacting with Akka Actors is simple. + +There are also tests showing how Junit and Akka Testkit are used to test actors and flows. + +For more information, please see the documentation for Websockets and Akka Streams: + +* https://www.playframework.com/documentation/2.5.x/JavaWebSockets +* http://doc.akka.io/docs/akka/current/java/stream/stream-flows-and-basics.html#stream-materialization +* http://doc.akka.io/docs/akka/current/java/stream/stream-integrations.html#integrating-with-actors diff --git a/app/Module.java b/app/Module.java new file mode 100644 index 000000000..348fef887 --- /dev/null +++ b/app/Module.java @@ -0,0 +1,13 @@ +import actors.*; +import com.google.inject.AbstractModule; +import play.libs.akka.AkkaGuiceSupport; + +@SuppressWarnings("unused") +public class Module extends AbstractModule implements AkkaGuiceSupport { + @Override + protected void configure() { + bindActor(StocksActor.class, "stocksActor"); + bindActor(UserParentActor.class, "userParentActor"); + bindActorFactory(UserActor.class, UserActor.Factory.class); + } +} diff --git a/app/actors/StockActor.java b/app/actors/StockActor.java index 89544475d..0f4a88b97 100644 --- a/app/actors/StockActor.java +++ b/app/actors/StockActor.java @@ -3,6 +3,8 @@ import akka.actor.AbstractActor; import akka.actor.ActorRef; import akka.actor.Cancellable; +import akka.event.Logging; +import akka.event.LoggingAdapter; import akka.japi.pf.ReceiveBuilder; import java.util.concurrent.TimeUnit; import java.util.Deque; @@ -18,9 +20,11 @@ */ public class StockActor extends AbstractActor { - final HashSet watchers = new HashSet(); + private LoggingAdapter log = Logging.getLogger(getContext().system(), this); - final Deque stockHistory = FakeStockQuote.history(50); + private final HashSet watchers = new HashSet(); + + private final Deque stockHistory = FakeStockQuote.history(50); public StockActor(String symbol) { this(symbol, new FakeStockQuote(), true); diff --git a/app/actors/StocksActor.java b/app/actors/StocksActor.java index 9abf55124..b90c52095 100644 --- a/app/actors/StocksActor.java +++ b/app/actors/StocksActor.java @@ -1,39 +1,43 @@ package actors; -import akka.actor.AbstractActor; import akka.actor.ActorRef; import akka.actor.Props; -import akka.japi.pf.ReceiveBuilder; +import akka.actor.UntypedActor; +import akka.event.Logging; +import akka.event.LoggingAdapter; + import java.util.Collections; import java.util.Optional; -import play.libs.Akka; -public class StocksActor extends AbstractActor { +/** + * + */ +public class StocksActor extends UntypedActor { - private static class LazyStocksActor { - public static final ActorRef ref = Akka.system().actorOf(Props.create(StocksActor.class)); - } + private LoggingAdapter log = Logging.getLogger(getContext().system(), this); - public static ActorRef stocksActor() { - return LazyStocksActor.ref; - } + @Override + public void onReceive(Object message) throws Exception { + + if (message instanceof Stock.Watch) { + Stock.Watch watch = (Stock.Watch) message; + String symbol = watch.symbol; + // get or create the StockActor for the symbol and forward this message + Optional.ofNullable(getContext().getChild(symbol)).orElseGet(() -> { + final Props props = Props.create(StockActor.class, symbol); + return context().actorOf(props, symbol); + } + ).forward(watch, context()); + } - public StocksActor() { - receive(ReceiveBuilder - .match(Stock.Watch.class, watch -> { - String symbol = watch.symbol; - // get or create the StockActor for the symbol and forward this message - Optional.ofNullable(getContext().getChild(symbol)).orElseGet( - () -> context().actorOf(Props.create(StockActor.class, symbol), symbol) - ).forward(watch, context()); - }) - .match(Stock.Unwatch.class, unwatch -> { - // forward this message to the associated StockActor, or otherwise to everyone - unwatch.symbol - .map(getContext()::getChild) - .>map(Collections::singletonList) - .orElse(getContext().getChildren()) - .forEach(child -> child.forward(unwatch, context())); - }).build()); + if (message instanceof Stock.Unwatch) { + Stock.Unwatch unwatch = (Stock.Unwatch) message; + // forward this message to the associated StockActor, or otherwise to everyone + unwatch.symbol + .map(getContext()::getChild) + .>map(Collections::singletonList) + .orElse(getContext().getChildren()) + .forEach(child -> child.forward(unwatch, context())); + } } } diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java index d94f6ab8d..20e8afcfe 100644 --- a/app/actors/UserActor.java +++ b/app/actors/UserActor.java @@ -1,43 +1,101 @@ package actors; -import akka.actor.AbstractActor; -import akka.japi.pf.ReceiveBuilder; +import akka.actor.Actor; +import akka.actor.ActorRef; +import akka.actor.UntypedActor; +import akka.event.Logging; +import akka.event.LoggingAdapter; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.inject.assistedinject.Assisted; +import play.Configuration; import play.libs.Json; -import play.mvc.WebSocket; + +import javax.inject.Inject; +import javax.inject.Named; +import java.util.List; /** * The broker between the WebSocket and the StockActor(s). The UserActor holds the connection and sends serialized * JSON data to the client. */ -public class UserActor extends AbstractActor { +public class UserActor extends UntypedActor { + + private LoggingAdapter logger = Logging.getLogger(getContext().system(), this); + + private ActorRef out; + private Configuration configuration; + private ActorRef stocksActor; + + @Inject + public UserActor (@Assisted ActorRef out, + @Named("stocksActor") ActorRef stocksActor, + Configuration configuration) { + this.out = out; + this.stocksActor = stocksActor; + this.configuration = configuration; + } - public UserActor(WebSocket.Out out) { - receive(ReceiveBuilder - .match(Stock.Update.class, stockUpdate -> { - // push the stock to the client - JsonNode message = + @Override + public void preStart() throws Exception { + super.preStart(); + + configureDefaultStocks(); + } + + public void configureDefaultStocks() { + List defaultStocks = configuration.getStringList("default.stocks"); + logger.info("Creating user actor with default stocks {}", defaultStocks); + + for (String stockSymbol : defaultStocks) { + stocksActor.tell(new Stock.Watch(stockSymbol), self()); + } + } + + public void onReceive(Object msg) throws Exception { + + if (msg instanceof Stock.Update) { + Stock.Update stockUpdate = (Stock.Update) msg; + // push the stock to the client + JsonNode message = Json.newObject() - .put("type", "stockupdate") - .put("symbol", stockUpdate.symbol) - .put("price", stockUpdate.price); - out.write(message); - }) - .match(Stock.History.class, stockHistory -> { - // push the history to the client - ObjectNode message = + .put("type", "stockupdate") + .put("symbol", stockUpdate.symbol) + .put("price", stockUpdate.price); + + logger.debug("onReceive: " + message); + + out.tell(message, self()); + } + + if (msg instanceof Stock.History) { + Stock.History stockHistory = (Stock.History) msg; + // push the history to the client + ObjectNode message = Json.newObject() - .put("type", "stockhistory") - .put("symbol", stockHistory.symbol); + .put("type", "stockhistory") + .put("symbol", stockHistory.symbol); - ArrayNode historyJson = message.putArray("history"); - for (Double price : stockHistory.history) { - historyJson.add(price); - } + ArrayNode historyJson = message.putArray("history"); + for (Double price : stockHistory.history) { + historyJson.add(price); + } + + logger.debug("onReceive: " + message); + + out.tell(message, self()); + } + + if (msg instanceof JsonNode) { + // When the user types in a stock in the upper right corner, this is triggered + JsonNode json = (JsonNode) msg; + final String symbol = json.get("symbol").textValue(); + stocksActor.tell(new Stock.Watch(symbol), self()); + } + } - out.write(message); - }).build()); + public interface Factory { + Actor create(ActorRef out); } } diff --git a/app/actors/UserParentActor.java b/app/actors/UserParentActor.java new file mode 100644 index 000000000..00f397638 --- /dev/null +++ b/app/actors/UserParentActor.java @@ -0,0 +1,37 @@ +package actors; + +import akka.actor.ActorRef; +import akka.actor.UntypedActor; +import play.libs.akka.InjectedActorSupport; + +import javax.inject.Inject; + +public class UserParentActor extends UntypedActor implements InjectedActorSupport { + + public static class Create { + private String id; + private ActorRef out; + + public Create(String id, ActorRef out) { + this.id = id; + this.out = out; + } + } + + private UserActor.Factory childFactory; + + @Inject + public UserParentActor(UserActor.Factory childFactory) { + this.childFactory = childFactory; + } + + @Override + public void onReceive(Object message) throws Exception { + if (message instanceof UserParentActor.Create) { + UserParentActor.Create create = (UserParentActor.Create) message; + ActorRef child = injectedChild(() -> childFactory.create(create.out), "userActor-" + create.id); + sender().tell(child, self()); + } + } + +} diff --git a/app/controllers/Application.java b/app/controllers/Application.java deleted file mode 100644 index b63f84bfc..000000000 --- a/app/controllers/Application.java +++ /dev/null @@ -1,49 +0,0 @@ -package controllers; - -import actors.*; -import akka.actor.*; -import akka.actor.ActorRef; -import com.fasterxml.jackson.databind.JsonNode; -import java.util.List; -import java.util.Optional; -import play.libs.Akka; -import play.mvc.Controller; -import play.mvc.Result; -import play.mvc.WebSocket; -import play.Play; - -/** - * The main web controller that handles returning the index page, setting up a WebSocket, and watching a stock. - */ -public class Application extends Controller { - - public Result index() { - return ok(views.html.index.render()); - } - - public WebSocket ws() { - return WebSocket.whenReady((in, out) -> { - // create a new UserActor and give it the default stocks to watch - final ActorRef userActor = Akka.system().actorOf(Props.create(UserActor.class, out)); - List defaultStocks = Play.application().configuration().getStringList("default.stocks"); - for (String stockSymbol : defaultStocks) { - StocksActor.stocksActor().tell(new Stock.Watch(stockSymbol), userActor); - } - - // send all WebSocket message to the UserActor - in.onMessage(jsonNode -> { - // parse the JSON into Stock.Watch - Stock.Watch watchStock = new Stock.Watch(jsonNode.get("symbol").textValue()); - // send the watchStock message to the StocksActor - StocksActor.stocksActor().tell(watchStock, userActor); - }); - - // on close, tell the userActor to shutdown - in.onClose(() -> { - StocksActor.stocksActor().tell(new Stock.Unwatch(Optional.empty()), userActor); - Akka.system().stop(userActor); - }); - }); - } - -} diff --git a/app/controllers/HomeController.java b/app/controllers/HomeController.java new file mode 100644 index 000000000..b601db9a2 --- /dev/null +++ b/app/controllers/HomeController.java @@ -0,0 +1,143 @@ +package controllers; + +import actors.Stock; +import actors.UserParentActor; +import akka.NotUsed; +import akka.actor.ActorRef; +import akka.actor.ActorSystem; +import akka.actor.Status; +import akka.japi.Pair; +import akka.stream.Materializer; +import akka.stream.OverflowStrategy; +import akka.stream.javadsl.*; +import com.fasterxml.jackson.databind.JsonNode; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import play.Configuration; +import play.libs.F.Either; +import play.mvc.Controller; +import play.mvc.Result; +import play.mvc.Results; +import play.mvc.WebSocket; +import scala.compat.java8.FutureConverters; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +import static akka.pattern.Patterns.ask; + +/** + * The main web controller that handles returning the index page, setting up a WebSocket, and watching a stock. + */ +@Singleton +public class HomeController extends Controller { + + private Logger logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController"); + + private Configuration configuration; + private ActorRef stocksActor; + private ActorRef userParentActor; + private Materializer materializer; + private ActorSystem actorSystem; + + @Inject + public HomeController(ActorSystem actorSystem, + Materializer materializer, + Configuration configuration, + @Named("stocksActor") ActorRef stocksActor, + @Named("userParentActor") ActorRef userParentActor) { + this.configuration = configuration; + this.stocksActor = stocksActor; + this.userParentActor = userParentActor; + this.materializer = materializer; + this.actorSystem = actorSystem; + } + + public Result index() { + return ok(views.html.index.render(request())); + } + + public WebSocket ws() { + return WebSocket.Json.acceptOrResult(request -> { + final CompletionStage> future = wsFutureFlow("foo"); + final CompletionStage>> stage = future.thenApplyAsync(Either::Right); + return stage.exceptionally(this::logException); + }); + } + + public CompletionStage> wsFutureFlow(String name) { + // create an actor ref source and associated publisher for sink + final Pair> pair = createWebSocketConnections(); + ActorRef webSocketOut = pair.first(); + Publisher webSocketIn = pair.second(); + + // Create a user actor off the request id and attach it to the source + final CompletionStage userActorFuture = createUserActor(name, webSocketOut); + + // Once we have an actor available, create a flow... + final CompletionStage> stage = userActorFuture + .thenApplyAsync(userActor -> createWebSocketFlow(webSocketIn, userActor)); + + return stage; + } + + public CompletionStage createUserActor(String name, ActorRef webSocketOut) { + // Use guice assisted injection to instantiate and configure the child actor. + long timeoutMillis = 100L; + return FutureConverters.toJava( + ask(userParentActor, new UserParentActor.Create(name, webSocketOut), timeoutMillis) + ).thenApply(stageObj -> (ActorRef) stageObj); + } + + public Pair> createWebSocketConnections() { + // Creates a source to be materialized as an actor reference. + + // Creating a source can be done through various means, but here we want + // the source exposed as an actor so we can send it messages from other + // actors. + final Source source = Source.actorRef(10, OverflowStrategy.dropTail()); + + // Creates a sink to be materialized as a publisher. Fanout is false as we only want + // a single subscriber here. + final Sink> sink = Sink.asPublisher(AsPublisher.WITHOUT_FANOUT); + + // Connect the source and sink into a flow, telling it to keep the materialized values, + // and then kicks the flow into existence. + final Pair> pair = source.toMat(sink, Keep.both()).run(materializer); + return pair; + } + + public Either> logException(Throwable throwable) { + // https://docs.oracle.com/javase/tutorial/java/generics/capture.html + logger.error("Cannot create websocket", throwable); + Result result = Results.internalServerError("error"); + return Either.Left(result); + } + + public Flow createWebSocketFlow(Publisher webSocketIn, ActorRef userActor) { + // http://doc.akka.io/docs/akka/current/scala/stream/stream-flows-and-basics.html#stream-materialization + // http://doc.akka.io/docs/akka/current/scala/stream/stream-integrations.html#integrating-with-actors + + // source is what comes in: browser ws events -> play -> publisher -> userActor + // sink is what comes out: userActor -> websocketOut -> play -> browser ws events + final Sink sink = Sink.actorRef(userActor, new Status.Success("success")); + final Source source = Source.fromPublisher(webSocketIn); + final Flow flow = Flow.fromSinkAndSource(sink, source); + + // Unhook the user actor when the websocket flow terminates + // http://doc.akka.io/docs/akka/current/scala/stream/stages-overview.html#watchTermination + return flow.watchTermination((ignore, termination) -> { + termination.whenComplete((done, throwable) -> { + logger.info("Terminating actor {}", userActor); + stocksActor.tell(new Stock.Unwatch(Optional.empty()), userActor); + actorSystem.stop(userActor); + }); + + return NotUsed.getInstance(); + }); + } + +} diff --git a/app/controllers/StockSentiment.java b/app/controllers/StockSentiment.java index c1d25c70a..6138dcd84 100644 --- a/app/controllers/StockSentiment.java +++ b/app/controllers/StockSentiment.java @@ -1,48 +1,86 @@ package controllers; import com.fasterxml.jackson.databind.JsonNode; -import java.util.List; -import java.util.stream.Stream; +import play.Configuration; import play.libs.Json; -import play.libs.ws.WS; +import play.libs.concurrent.Futures; +import play.libs.concurrent.HttpExecutionContext; +import play.libs.ws.WSClient; import play.libs.ws.WSResponse; -import play.mvc.*; -import play.Play; +import play.mvc.Controller; +import play.mvc.Http; +import play.mvc.Result; +import play.mvc.Results; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import static java.util.stream.Collectors.averagingDouble; import static java.util.stream.Collectors.toList; -import static play.libs.F.Promise; import static utils.Streams.stream; +@Singleton public class StockSentiment extends Controller { - public Promise get(String symbol) { + private WSClient wsClient; + private Configuration configuration; + private HttpExecutionContext ec; + + @Inject + public StockSentiment(WSClient wsClient, Configuration configuration, HttpExecutionContext ec) { + this.wsClient = wsClient; + this.configuration = configuration; + this.ec = ec; + } + + public CompletionStage get(String symbol) { return fetchTweets(symbol) - .flatMap(StockSentiment::fetchSentiments) - .map(StockSentiment::averageSentiment) - .map(Results::ok) - .recover(StockSentiment::errorResponse); + .thenComposeAsync(this::fetchSentiments) + .thenApplyAsync(this::averageSentiment) + .thenApplyAsync(Results::ok) + .exceptionally(this::errorResponse); } - private static Promise> fetchTweets(String symbol) { - return WS.url(Play.application().configuration().getString("tweet.url")) - .setQueryParameter("q", "$" + symbol).get() - .filter(response -> response.getStatus() == Http.Status.OK) - .map(response -> stream(response.asJson().findPath("statuses")) - .map(s -> s.findValue("text").asText()) - .collect(toList())); + private CompletionStage> fetchTweets(String symbol) { + + final String tweetUrl = configuration.getString("tweet.url"); + final CompletionStage futureResponse = wsClient.url(tweetUrl) + .setQueryParameter("q", "$" + symbol) + .get(); + + final CompletionStage filter = futureResponse.thenApplyAsync(response -> { + if (response.getStatus() == Http.Status.OK) { + return response; + } else { + return null; + } + }, ec.current()); + + return filter.thenApplyAsync(response -> { + final List statuses = stream(response.asJson().findPath("statuses")) + .map(s -> s.findValue("text").asText()) + .collect(Collectors.toList()); + return statuses; + }); } - private static Promise> fetchSentiments(List tweets) { - String url = Play.application().configuration().getString("sentiment.url"); - Stream> sentiments = tweets.stream().map(text -> WS.url(url).post("text=" + text)); - return Promise.sequence(sentiments::iterator).map(StockSentiment::responsesAsJson); + private CompletionStage> fetchSentiments(List tweets) { + String url = configuration.getString("sentiment.url"); + Stream> sentiments = tweets.stream().map(text -> { + return wsClient.url(url).post("text=" + text); + }); + return Futures.sequence(sentiments::iterator).thenApplyAsync(this::responsesAsJson); } - private static List responsesAsJson(List responses) { + private List responsesAsJson(List responses) { return responses.stream().map(WSResponse::asJson).collect(toList()); } - private static JsonNode averageSentiment(List sentiments) { + private JsonNode averageSentiment(List sentiments) { double neg = collectAverage(sentiments, "neg"); double neutral = collectAverage(sentiments, "neutral"); double pos = collectAverage(sentiments, "pos"); @@ -50,18 +88,18 @@ private static JsonNode averageSentiment(List sentiments) { String label = (neutral > 0.5) ? "neutral" : (neg > pos) ? "neg" : "pos"; return Json.newObject() - .put("label", label) - .set("probability", Json.newObject() - .put("neg", neg) - .put("neutral", neutral) - .put("pos", pos)); + .put("label", label) + .set("probability", Json.newObject() + .put("neg", neg) + .put("neutral", neutral) + .put("pos", pos)); } - private static double collectAverage(List jsons, String label) { + private double collectAverage(List jsons, String label) { return jsons.stream().collect(averagingDouble(json -> json.findValue(label).asDouble())); } - private static Result errorResponse(Throwable ignored) { + private Result errorResponse(Throwable ignored) { return internalServerError(Json.newObject().put("error", "Could not fetch the tweets")); } } diff --git a/app/views/index.scala.html b/app/views/index.scala.html index 66f4bdb63..f9d4b586a 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -1,6 +1,6 @@ +@(request: play.mvc.Http.Request) -@import play.mvc.Http.Context.Implicit._ @@ -12,7 +12,7 @@ - +