Forráskód Böngészése

Added recipe edit page and robust user system.

(going to be honest, I forgot to commit for a few months and don't
remember a lot of details of these changes)

Added permission system for users.

Added an automatic view manager with distinct view objects.

Added typeahead for food search.  Needs a better search algorithm.

Added way to include USDA foods directly in a recipe without importing
them.

Added nutrition pane component.

Added adaptive navbar which shows which views have permission.

Added "initalizer" page to set up an admin the first time the web site
is loaded.
Thomas Flucke 5 éve
szülő
commit
c421638c87
39 módosított fájl, 1791 hozzáadás és 248 törlés
  1. 1 0
      .gitignore
  2. 29 8
      README.md
  3. 14 9
      build.sbt
  4. 10 4
      client/src/main/scala/com/weEat/Main.scala
  5. 33 21
      client/src/main/scala/com/weEat/OAuthManager.scala
  6. 31 0
      client/src/main/scala/com/weEat/modules/Navbar.scala
  7. 305 0
      client/src/main/scala/com/weEat/modules/NutritionPane.scala
  8. 4 3
      client/src/main/scala/com/weEat/modules/Overlay.scala
  9. 6 0
      client/src/main/scala/com/weEat/modules/README.md
  10. 0 4
      client/src/main/scala/com/weEat/modules/SearchBar.scala
  11. 36 0
      client/src/main/scala/com/weEat/modules/TypeaheadRx.scala
  12. 23 7
      client/src/main/scala/com/weEat/modules/USDAEditor.scala
  13. 86 21
      client/src/main/scala/com/weEat/util/MHtmlHelpers.scala
  14. 16 0
      client/src/main/scala/com/weEat/util/TimeoutHelper.scala
  15. 5 0
      client/src/main/scala/com/weEat/views/README.md
  16. 320 0
      client/src/main/scala/com/weEat/views/RecipeEdit.scala
  17. 6 5
      client/src/main/scala/com/weEat/views/UsdaImporter.scala
  18. 31 0
      client/src/main/scala/com/weEat/views/UserManage.scala
  19. 22 5
      client/src/main/scala/com/weEat/views/View.scala
  20. 2 1
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResult.scala
  21. 4 3
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResultFood.scala
  22. 91 8
      server/app/com/weEat/controllers/FoodController.scala
  23. 12 6
      server/app/com/weEat/controllers/USDAController.scala
  24. 53 2
      server/app/com/weEat/controllers/ViewController.scala
  25. 3 7
      server/app/com/weEat/migrations/InitDb.scala
  26. 1 1
      server/app/com/weEat/migrations/Migration.scala
  27. 15 0
      server/app/com/weEat/models/FoodImages.scala
  28. 1 1
      server/app/com/weEat/models/FoodNode.scala
  29. 16 7
      server/app/com/weEat/models/User.scala
  30. 84 9
      server/app/com/weEat/services/MongoDBService.scala
  31. 17 9
      server/app/com/weEat/services/OAuth2Service.scala
  32. 46 0
      server/app/views/initalizer.scala.html
  33. 62 15
      server/app/views/viewLoader.scala.html
  34. 7 0
      server/conf/application.conf
  35. 34 12
      server/conf/routes
  36. 281 77
      shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala
  37. 4 2
      shared/shared/src/main/scala/com/weEat/shared/models/GrantRequest.scala
  38. 77 0
      shared/shared/src/main/scala/com/weEat/shared/models/MeasureUnit.scala
  39. 3 1
      shared/shared/src/main/scala/com/weEat/shared/models/User.scala

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 target
+*.log

+ 29 - 8
README.md

@@ -2,7 +2,25 @@
 
 ## Setup and Usage
 
+### Requirements
 
+* sbt (build)
+* scalac (build)
+* jvm
+* mongodb
+
+### Test environment
+
+Run the command: `sbt run`
+
+The server code will automatically compile any changes each time a request is made
+to the server.
+
+The client code will automatically compile any changes each time the web page is
+loaded.
+
+The database will apply any migrations not yet applied to the database each time
+any access is made to the database.
 
 ## TODO List:
 
@@ -20,28 +38,29 @@ February:
  * DB Seed (done)
 
 March:
- * Add Recipe
- * Calculate nutritional information
- * Attach photos
+ * Typeahead (done)
 
 April:
+ * Add Recipe (done)
+ * Calculate nutritional information (done)
+
+May:
  * User profile setup
  * Recipe Version tracking
 
-May:
+June:
  * Search Recipe
  * Search by ingredient
 
-June:
+July:
  * HTML Importer
  * Change Log
 
-July:
+August:
  * Calories Adjustments from cooking
  * DB Caching
  * REST Caching
-
-August:
+ * Attach photos
 
 September:
 
@@ -66,3 +85,5 @@ Stretch Goals:
  * Parse recipe from photo
  * Seasonal food search
  * substitute recipe in place of ingredient
+ * Admin Panel
+   * DB Reset

+ 14 - 9
build.sbt

@@ -1,8 +1,11 @@
 def commonSettings = Seq(
   scalaVersion := "2.13.3",
   version := "0.1.0",
+  scalacOptions ++= Seq(
+    "-deprecation",
+    "-unchecked"
+  )
 )
-
 lazy val server: Project = (project in file("server"))
   .settings(commonSettings)
   .settings(
@@ -22,6 +25,8 @@ lazy val server: Project = (project in file("server"))
       "org.webjars" %% "webjars-play" % "2.8.0",
       "org.webjars" % "jquery" % "3.4.1",
       "org.webjars" % "bootstrap" % "4.4.1-1",
+      "org.webjars" % "jquery-ui" % "1.12.1",
+      "org.webjars.npm" % "sortablejs" % "1.13.0",
       // Mogno + ORM
       "org.mongodb.scala" %% "mongo-scala-driver" % "4.1.0",
       // Testing
@@ -39,13 +44,11 @@ lazy val client = (project in file("client"))
   .settings(commonSettings)
   .settings(
     scalaJSUseMainModuleInitializer := true,
-    Compile / apiDefinitions += PlayEndpointFile(server),
-    libraryDependencies += "org.querki" %%% "jquery-facade"  % "2.0",
-    libraryDependencies += "in.nvilla"  %%% "monadic-html" % "0.4.1",
-    libraryDependencies += "in.nvilla"  %%% "monadic-rx-cats" % "0.4.1",
-      // libraryDependencies += "com.thoughtworks.binding" %%% "binding" % "12.0.1+8-8f3c7f31",
-    // libraryDependencies += "org.lrng.binding" %%% "html" % "1.0.3",
-    // libraryDependencies += "org.scala-lang.modules" %%% "scala-xml" % "2.0.0-M2",
+    libraryDependencies += "org.querki"  %%% "jquery-facade"  % "2.0",
+    libraryDependencies += "in.nvilla"   %%% "monadic-html" % "0.4.1",
+    libraryDependencies += "in.nvilla"   %%% "monadic-rx-cats" % "0.4.1",
+    libraryDependencies += "com.tflucke" %%% "typeahead-scala" % "0.2.1",
+    libraryDependencies += "com.tflucke" %%% "sortablejs-facade" % "1.13.0",
     scalacOptions ++= {
       import Ordering.Implicits._
       if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L))
@@ -53,7 +56,7 @@ lazy val client = (project in file("client"))
       else Nil
     }
   )
-  .enablePlugins(ScalaJSPlugin, RestRPC, ScalaJSWeb)
+  .enablePlugins(ScalaJSPlugin, ScalaJSWeb)
   .dependsOn(sharedJs)
   .dependsOn(fdcJs)
 
@@ -61,10 +64,12 @@ lazy val shared = crossProject(JSPlatform, JVMPlatform)
   .in(file("shared"))
   .settings(commonSettings)
   .settings(
+    Compile / apiDefinitions += PlayEndpointFile(server),
     libraryDependencies += "com.typesafe.play" %%% "play-json" % "2.9.2",
     // Temporary workaround due to mongodb limitations
     libraryDependencies += "org.julienrf" %%% "play-json-derived-codecs" % "8.0.0"
   )
+  .enablePlugins(RestRPC)
   .jsConfigure(_ enablePlugins ScalaJSWeb)
 lazy val sharedJvm = shared.jvm.dependsOn(fdcJvm).settings(
   libraryDependencies += "org.mongodb.scala" %% "mongo-scala-bson" % "4.1.0"

+ 10 - 4
client/src/main/scala/com/weEat/Main.scala

@@ -2,6 +2,7 @@ package com.weEat
 
 import com.tflucke.webroutes.Headers
 import com.weEat.view.View
+import com.weEat.modules.Navbar
 import org.querki.jquery.{JQueryStatic => $}
 import org.scalajs.dom.{document,window}
 import org.scalajs.dom.raw.HTMLInputElement
@@ -24,13 +25,18 @@ object Main {
   def main(args: Array[String]): Unit = {
     import org.scalajs.dom.experimental.URLSearchParams
 
-    OAuthManager.refreshToken
+    OAuthManager.refreshToken()
+
     $("#btn-login").click(OAuthManager.promptLogin _)
     $("#btn-signup").click(OAuthManager.promptSignup _)
     $("#btn-logout").click(OAuthManager.logout _)
 
-    View.fromTag(
-      new URLSearchParams(window.location.search).get("t")
-    ).present
+    val navbar = Navbar(View.authedIndex)
+    mhtml.mount(
+      document.getElementById("navbarNav"),
+      navbar.render
+    )
+    val params = new URLSearchParams(window.location.search)
+    navbar.renderView(View.fromTag(params.get("t")))
   }
 }

+ 33 - 21
client/src/main/scala/com/weEat/OAuthManager.scala

@@ -13,52 +13,59 @@ import scala.concurrent.Future
 import scala.scalajs.js
 import scala.scalajs.js.timers
 import scala.util.{Try,Success,Failure}
+import mhtml.{Rx,Var}
 
 object OAuthManager {
+  import Main.headers
+
+  private val _scope = Var(Set.empty[String])
+
   def authHeader = SessionStorage.get("access-token").map({ token =>
     ("Authorization" -> token)
   })
 
+  def isAuthedFor(perms: String) = _scope.map(_.contains(perms))
+
   def promptLogin() =
     $("body").append(overlayWindow("/assets/views/login.html", login))
 
   def promptSignup() =
     $("body").append(overlayWindow("/assets/views/register.html", signup))
 
-  def refreshToken: Unit = {
+  def refreshToken(): Future[Unit] = {
     SessionStorage.remove("access-token")
     SessionStorage.get("username").map({user =>
       SessionStorage.get("refresh-token").map({refresh =>
         UserController.accessToken()(RefreshRequest(user, refresh))
           .map(loginComplete(user)).recover({
-            case _ => SessionStorage.remove("username").remove("refresh-token")
-          })
+            case _ => clear()
+          }).map(_ => ())
       })
     }).flatten
+      .getOrElse(Future.failed(new IllegalStateException("No token present")))
   }
 
   def loginComplete(user: String)(auth: UserAuthorization) = {
-    SessionStorage.set("access-token",
-      "%s %s".format(auth.tokenType, auth.accessToken)
-    )
+    SessionStorage.set("access-token", s"${auth.tokenType} ${auth.accessToken}")
     SessionStorage.set("username", user)
     SessionStorage.set("refresh-token", auth.refreshToken)
+    _scope := auth.scope
     timers.setTimeout(auth.expiresIn)(refreshToken)
-    $("#login-btns").hide
-    $("#logout-btns").show
+    $("#login-btns").hide()
+    $("#logout-btns").show()
   }
 
   def signup(div: JQuery): Future[Any] = {
     import java.util.InputMismatchException
 
-    val email = div.find("#email").value.toString
-    val password = div.find("#password").value.toString
-    (if (!password.equals(div.find("#password2").value.toString))
+    val email = div.find("#email").value().toString
+    val password = div.find("#password").value().toString
+    (if (!password.equals(div.find("#password2").value().toString))
       Future.failed(new InputMismatchException("Passwords do not match."))
     else
       UserController.registerUser()(UserRegistration(
-        div.find("#fname").value.toString,
-        div.find("#lname").value.toString,
+        div.find("#fname").value().toString,
+        div.find("#lname").value().toString,
         email,
         password
       )) map(loginComplete(email))) andThen({
@@ -74,17 +81,17 @@ object OAuthManager {
 
   def showError(div: JQuery, msg: String) = div.find(".alert-danger").html(
     s"<strong>Error:</strong> $msg"
-  ).show
+  ).show()
 
   def parseString[T](implicit reader: play.api.libs.json.Reads[T]) = {
     str: String => Json.parse(str).as[T]
   }
 
   def login(div: JQuery): Future[Any] = {
-    val email = div.find("#email").value.toString
+    val email = div.find("#email").value().toString
     implicit var headers = Main.headers +
       ("Authorization" -> ("Basic " + base64Encode(
-        "%s:%s".format(email, div.find("#password").value)
+        "%s:%s".format(email, div.find("#password").value())
       )))
     UserController.accessToken()(PasswordRequest()).andThen({
       case Success(auth) => loginComplete(email)(auth)
@@ -98,17 +105,22 @@ object OAuthManager {
     })
   }
 
-  def logout: Future[Unit] = SessionStorage.get("username").map({user =>
+  def logout(): Future[Unit] = SessionStorage.get("username").map({user =>
     SessionStorage.get("refresh-token").map({ refresh: String =>
       UserController.revokeAccessToken()(RefreshRequest(user, refresh))
     })
   }).flatten.getOrElse(Future.failed(
     new IllegalStateException("No login information.")
   )).map({ _ =>
-    SessionStorage.remove("username").remove("refresh-token")
+    clear()
     document.location.reload(false)
   })
 
+  def clear() = {
+    SessionStorage.remove("username").remove("refresh-token")
+    _scope := Set.empty[String]
+  }
+
   def shadeWindow(cancelFn: Option[(() => Unit)]) = {
     val shadeDiv = $("<div>").css(js.Dictionary[js.Any](
       "z-index" -> 99,
@@ -126,7 +138,7 @@ object OAuthManager {
           case Some(fn) => fn()
           case None =>
         }
-        $(event.target).detach
+        $(event.target).detach()
       }
     })
   }
@@ -150,12 +162,12 @@ object OAuthManager {
       (elm: Element, resp: String, status: String, xhr: JQueryXHR) => {
         $(elm).find("*[data-cb='cancel']").click((event: JQueryEventObject) => {
           cancelFn.map({fn => fn()})
-          shadeDiv.detach
+          shadeDiv.detach()
         })
         val div =  $(elm).find("*[data-cb='success']").click(
           (event: JQueryEventObject) => {
             submitFn(promptDiv) onComplete {
-              case Success(_) => shadeDiv.detach
+              case Success(_) => shadeDiv.detach()
               case Failure(err) => System.err.println(err)
             }
           }

+ 31 - 0
client/src/main/scala/com/weEat/modules/Navbar.scala

@@ -0,0 +1,31 @@
+package com.weEat.modules
+
+import mhtml.{Rx,Cancelable}
+import com.weEat.view.View
+
+case class Navbar(nav: Rx[Seq[View]]) extends Module {
+
+  private var lastCancelable: Option[Cancelable] = None
+
+  def renderView(view: View) = {
+    lastCancelable.foreach(_.cancel)
+    lastCancelable = Some(view.present())
+    ()
+  }
+
+  val render = {
+    <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
+    {
+      nav.map({
+        for (view <- _) yield {
+          <li class={if (false) "nav-item active" else "nav-item"}>
+            <a class="nav-link" href="#" onclick={() =>
+              renderView(view)
+            }>{view.title}</a>
+          </li>
+        }
+      })
+    }
+    </ul>
+  }
+}

+ 305 - 0
client/src/main/scala/com/weEat/modules/NutritionPane.scala

@@ -0,0 +1,305 @@
+package com.weEat.modules
+
+import com.weEat.modules._
+import com.weEat.shared.models.{FoodNode, UnitType}
+import mhtml.Rx
+import scala.xml.Elem
+import scala.concurrent.ExecutionContext
+import org.scalajs.dom.raw.HTMLInputElement
+import scala.scalajs.js.timers.setTimeout
+import scala.concurrent.duration._
+import scala.language.postfixOps
+import com.weEat.util.MHtmlHelpers._
+import scala.concurrent.Future
+import scala.util.{Success,Failure}
+import mhtml.future.syntax._
+import scala.scalajs.js.`|`
+
+case class NutritionPane(val food: Rx[FoodNode])
+    extends Module {
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  private val _daily = Map(
+    "204" -> 78f,   // Total Fat
+    "606" -> 20f,   // Saturated Fat
+    "601" -> 300f,  // Cholesterol
+    "605" -> 2f,    // Trans fats
+    "307" -> 2300f, // Sodium
+    "205" -> 275f,  // Total Carbohydrates
+    "291" -> 28f,   // Dietary Fiber
+    "320" -> 800f,  // Vitamin A (900 M, 700 F)
+    "415" -> (1.15f + 1.2f + 15f + 1.3f + 2.4f + 5f), // Vitamin B
+    "401" -> 88f,   // Vitamin C (90 M, 75 F)
+    "324" -> 15f,   // Vitamin D
+    "301" -> 1300f, // Calcium
+    "303" -> 13f,   // Iron (8 M, 18 F)
+    "306" -> 4700f, // Potassium
+    "432" -> 400f   // Folate
+  )
+  private val _caloriesInFat = 8.84f
+
+  private def _show(decimals: Int)(getNutrient: FoodNode => Future[Float]) =
+    food.map(getNutrient(_).toRx).flatten.map({
+      case Some(Success(v)) => s"%.${decimals}f".format(v)
+      case Some(Failure(e)) =>
+        println(e)
+        "err"
+      case None => ""
+    })
+
+  private def _showDaily(nutrient: String) =
+    (_show(0) { _.nutrient(nutrient).map(_ / _daily(nutrient) * 100.0f) })
+      .map(_ + "%")
+
+  private def _showDailyLine(
+    title: Elem,
+    dec: Int,
+    unit: String,
+    nutrient: String
+  ): Elem =
+    <div class="line">
+      <div class="nutrLabel">Total {title}
+        <div class="weight">{ _show(dec) { _.nutrient(nutrient) } }{ unit }</div>
+      </div>
+      <div class="dv">
+        { _showDaily(nutrient) }
+      </div>
+    </div>
+
+  private def _showDailyLine(
+    title: String,
+    dec: Int,
+    unit: String,
+    nutrient: String
+  ): Elem = _showDailyLine( <span>{title}</span>, dec, unit, nutrient)
+
+  val render = <div>
+  <style type="text/css">
+    {"""#nutritionfacts { 
+        background-color:white; 
+        border:1px solid black; 
+        padding:3px;
+        padding-right:5px;
+        width:244px; 
+    }
+    #nutritionfacts td { 
+        color:black; 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+        font-size:8pt; 
+        padding:0; 
+    }
+    #nutritionfacts td.header { 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+        font-size:28px; 
+        white-space:nowrap; 
+    }        
+    #nutritionfacts div.nutrLabel { 
+        float:left; 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+    }
+    #nutritionfacts div.serving { 
+        font-family:Arial,Helvetica,sans-serif; 
+        font-size:8pt; 
+        text-align:center; 
+    }
+    #nutritionfacts div.weight { 
+        display:inline; 
+        font-family:Arial,Helvetica,sans-serif; 
+        padding-left:1px; 
+    }
+    #nutritionfacts div.dv { 
+        display:inline; 
+        float:right; 
+        font-family:'Arial Black','Helvetica Bold',sans-serif; 
+    }
+    #nutritionfacts table.vitamins td {  
+        font-family:Arial,Helvetica,sans-serif; 
+        white-space:nowrap; 
+        width:33%; 
+    }
+    #nutritionfacts div.line { 
+        border-top:1px solid black; 
+    }
+    #nutritionfacts div.nutrLabellight { 
+        float:left; 
+        font-family:Arial,Helvetica,sans-serif; 
+    }
+    #nutritionfacts .highlighted {
+        border:1px dotted grey;
+        padding:2px;
+    }"""}
+  </style>
+
+  <div id="nutritionfacts">
+    <table style="width: 100%; border-spacing: 0;" cellpadding="0">
+      <tbody>
+        <tr>
+          <td style="text-align: center;" class="header">Nutrition Facts</td>
+        </tr>
+        <tr>
+          <td>
+            <div class="serving">
+              Per <span class="highlighted">
+                {food.map({f => f.defaultUnitType.standardQuanity})}
+                {food.map({f => f.defaultUnitType.defaultUnit.abr})}
+              </span>
+              Serving Size
+            </div>
+          </td>
+        </tr>
+        <tr style="height: 7px">
+          <td style="background-color: #000000;"></td>
+        </tr>
+        <tr>
+          <td style="font-size: 7pt">
+            <div class="line">Amount Per Serving</div>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <div class="line">
+              <div class="nutrLabel">
+                Calories
+                <div class="weight">{ _show(0) { _.nutrient("208") } }</div>
+              </div>
+              <div style="padding-top: 1px; float: right;" class="nutrLabellight">
+                Calories from Fat
+                <div class="weight">
+                  { _show(0) { _.nutrient("204").map(_*_caloriesInFat) } }
+                </div>
+              </div>
+            </div>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <div class="line">
+              <div class="dvnutrLabel">% Daily Value<sup>*</sup></div>
+            </div>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            { _showDailyLine("Fat", 0, "g", "204") }
+          </td>
+        </tr>
+        <tr>
+          <td class="indent">
+            { _showDailyLine("Saturated Fat", 1, "g", "606") }
+          </td>
+        </tr>
+        <tr>
+          <td class="indent">
+            { _showDailyLine(<span><i>Trans</i> Fat</span>, 1, "g", "605") }
+          </td>
+        </tr>
+        <tr>
+          <td>
+            { _showDailyLine("Cholesterol", 1, "mg", "601") }
+          </td>
+        </tr>
+        <tr>
+          <td>
+            { _showDailyLine("Sodium", 1, "mg", "307") }
+          </td>
+        </tr>
+        <tr>
+          <td>
+            { _showDailyLine("Total Carbohydrates", 1, "g", "205") }
+          </td>
+        </tr>
+        <tr>
+          <td class="indent">
+            { _showDailyLine("Dietary Fiber", 1, "g", "291") }
+          </td>
+        </tr>
+        <tr>
+          <td class="indent">
+            <div class="line">
+              <div class="nutrLabellight">
+                Sugars
+                <div class="weight">{ _show(1) { _.nutrient("269") } }g</div>
+              </div>
+            </div>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <div class="line">
+              <div class="nutrLabel">Protein
+                <div class="weight">{ _show(1) { _.nutrient("203") } }g</div>
+              </div>
+          </div></td>
+        </tr>
+        <tr style="height: 7px">
+          <td style="background-color: #000000;"></td>
+        </tr>
+        <tr>
+          <td>
+            <table style="border-style: none; border-spacing: 0; width: 100%;"
+                   cellpadding="0" class="vitamins">
+              <tbody>
+                <tr>
+                  <td>
+                    Vitamin A {"  "}
+                    { _showDaily("320") }
+                  </td>
+                  <td style="text-align: center;">•</td>
+                  <td style="text-align: right;">
+                    Calcium {"  "}
+                    { _showDaily("301") }
+                  </td>
+                </tr>
+                <tr>
+                  <td>
+                    Vitamin B {"  "}
+                    { _showDaily("415") }
+                  </td>
+                  <td style="text-align: center;">•</td>
+                  <td style="text-align: right;">
+                    Iron {"  "}
+                    { _showDaily("303") }
+                  </td>
+                </tr>
+                <tr>
+                  <td>
+                    Vitamin C {"  "}
+                    { _showDaily("401") }
+                  </td>
+                  <td style="text-align: center;">•</td>
+                  <td style="text-align: right;">
+                    Potassium {"  "}
+                    { _showDaily("306") }
+                  </td>
+                </tr>
+                <tr>
+                  <td>
+                    Vitamin D {"  "}
+                    { _showDaily("324") }
+                  </td>
+                  <td style="text-align: center;">•</td>
+                  <td style="text-align: right;">
+                    Folate {"  "}
+                    { _showDaily("432") }
+                  </td>
+                </tr>                        
+            </tbody></table>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <div class="line">
+              <div class="nutrLabellight">
+                * Based on a regular 2000 calorie diet<br />
+                <br />
+                <i>Nutritional details are an estimate and should only be used as a
+                  guide for approximation.</i>
+              </div>
+            </div>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
+}

+ 4 - 3
client/src/main/scala/com/weEat/modules/Overlay.scala

@@ -20,6 +20,7 @@ case class Overlay(
     div.style.zIndex = "99"
     div.style.backgroundColor = "rgba(0, 0, 0, 0.5)"
     div.style.position = "fixed"
+    div.style.textAlign = "center"
     div.style.top = "0"
     div.style.left = "0"
     div.style.right = "0"
@@ -28,7 +29,7 @@ case class Overlay(
   }
   
   val render = {
-    <div class="container w-50" style="margin: auto; padding: 1em; background-color: #ffffff; position: relative; border-radius: 1em; top: 50%; transform: translateY(-50%); max-height: 80%; overflow-y: auto;">
+    <div class="p-4 position-relative" style="background-color: #ffffff; border-radius: 1em; top: 50%; transform: translateY(-50%); max-height: 80%; overflow-y: auto; display: inline-block; vertical-align: middle; max-width: 50%;">
       {content}
     </div>
   }
@@ -53,7 +54,7 @@ object Overlay {
     val overlayFut = overlayPromise.future
     val wrappedContent = <div>
       {content}
-      <div style="width: 100%; text-align: right; padding: 0 1em 1em 0;">
+      <div class="pt-2 pb-2" style="text-align: right;">
         <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
           cancelFn.map({fn => fn()})
           overlayFut.map(_.selfDestruct())
@@ -86,7 +87,7 @@ object Overlay {
     val overlayFut = overlayPromise.future
     val wrappedContent = <div>
       {content}
-      <div style="width: 100%; text-align: right; padding: 0 1em 1em 0;">
+      <div class="pt-2 pb-2" style="text-align: right;">
         <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
           cancelFn.map({fn => fn()})
           overlayFut.map(_.selfDestruct())

+ 6 - 0
client/src/main/scala/com/weEat/modules/README.md

@@ -0,0 +1,6 @@
+# Modules
+
+Each module represents a distinct UI element.  A module object has a one-to-one
+relationship with the UI element it represents.  The HTML can be accessed through
+the `render` property.  Some modules include other properties, most notably `value`
+property which is used among user input modules.

+ 0 - 4
client/src/main/scala/com/weEat/modules/SearchBar.scala

@@ -26,10 +26,6 @@ case class SearchBar[T](
     }
     promise.future
   }
-  implicit class RxFlattener[T](outer: Rx[Rx[T]]) {
-    def flatten = outer.flatMap(identity)
-  }
-
 
   private val (_render, _inputTerm) =
     (<input type="text" class="form-control input-sm" />).value()

+ 36 - 0
client/src/main/scala/com/weEat/modules/TypeaheadRx.scala

@@ -0,0 +1,36 @@
+package com.weEat.modules
+
+import com.weEat.modules._
+import mhtml.Var
+import com.tflucke.typeahead._
+import scala.concurrent.ExecutionContext
+import org.scalajs.dom.raw.HTMLInputElement
+import scala.scalajs.js.timers.setTimeout
+import scala.concurrent.duration._
+import scala.language.postfixOps
+import com.weEat.util.MHtmlHelpers._
+
+case class TypeaheadRx[T](val datasets: Dataset[T]*)
+(implicit val ec: ExecutionContext = ExecutionContext.global) extends Module {
+
+  private val _query = Var("")
+  private val _result = Var[Option[T]](None)
+
+  val render = <input type="text" class="form-control input-sm" />
+    .afterMount { elm =>
+      // If we try to mount it immediately, the parent won't exist yet.
+      // Wait for the mounting to finish, then wrap in typeahead
+      TypeaheadElement(
+        elm.asInstanceOf[HTMLInputElement]
+      )(datasets)
+      elm.addEventListener("typeahead:selected", { e: CursorEvent[T] =>
+        _result := e.selectable.map(_.data)
+      })
+      elm.addEventListener("typeahead:queryChanged", { e: QueryEvent =>
+        _query := e.query
+      })
+    }
+
+  val query = _query.impure.sharing
+  val value = _result.impure.sharing
+}

+ 23 - 7
client/src/main/scala/com/weEat/modules/USDAEditor.scala

@@ -6,14 +6,17 @@ import com.weEat.shared.models.UnitType._
 import com.weEat.models.Nutrient
 import scala.util.{Try,Success,Failure}
 import com.weEat.shared.models.IdentifierHelper._
-import com.weEat.shared.models.USDANode
+import com.weEat.shared.models.{USDANode,USDANodeNoId,USDANodeId}
 import gov.usda.nal.fdc.models.{SearchResultFood,FullFoodItem,FoodPortion}
 import com.weEat.shared.models.{Count,MeasureUnit,USDANode}
 import mhtml.Rx
 import mhtml.future.syntax._
 import com.weEat.util.MHtmlHelpers._
 
-case class USDAEditor(usda: USDANode, defaultOverNone: Boolean) extends Module {
+case class USDAEditor(
+  usda: USDANode,
+  defaultOverNone: Boolean
+) extends Module {
   implicit val ctx = scala.concurrent.ExecutionContext.global
 
   private val _mapPortionMeasure =
@@ -97,7 +100,8 @@ case class USDAEditor(usda: USDANode, defaultOverNone: Boolean) extends Module {
              .keepIf(_.nonEmpty)(Some(0))
              .map(_.get.toString)} />.value()
 
-  private val (_unitTypeIn, _typ) = <select id="unit" class="form-control">
+  private val (_unitTypeIn, _typ) = <select id="unit" class="form-control"
+    readonly={true}>
   {
     for (unitType <- UnitType.values.toSeq)
     yield {<option value={unitType.toString}>{unitType.toString}</option>}
@@ -115,8 +119,20 @@ case class USDAEditor(usda: USDANode, defaultOverNone: Boolean) extends Module {
     min="0"
     value={value.getOrElse(0.0).toString} />
 
-  def getUSDANode(id: Option[Identifier]) = USDANode(
-    id,
+  def getUSDANode() = (usda match {
+    case node: USDANodeId =>
+      Function.uncurried((USDANodeId.apply _).curried(node._id))
+        .asInstanceOf[(
+          String,
+          Long,
+          Option[Float],
+          Option[Float],
+          Float,
+          Map[String, Float]
+        ) => USDANodeId]
+    case _ =>
+      USDANodeNoId.apply _
+  })(
     _name.value,
     _fdcId,
     _volume.flatMap({
@@ -129,7 +145,7 @@ case class USDAEditor(usda: USDANode, defaultOverNone: Boolean) extends Module {
       case `customValue` => _mass.map(str => Some(str.toFloat))
       case value => Rx(Some(value.toFloat))
     }).value,
-    _typ.map(UnitType.withName).value,
+    //_typ.map(UnitType.withName).value,
     _calorie.value.toFloat,
     _nutrientInputs.map({case (k, v) => (k, v._2.value.toFloat)})
   )
@@ -235,7 +251,7 @@ case class USDAEditor(usda: USDANode, defaultOverNone: Boolean) extends Module {
         </div>
         <div id="detailsDiv" class="panel-collapse collapse">
           <div class="panel-body">
-            <div class="container" style="width: 100%;">
+            <div class="container">
               <div class="row">
                 {
                   (for ((id, (input, _)) <- _nutrientInputs)

+ 86 - 21
client/src/main/scala/com/weEat/util/MHtmlHelpers.scala

@@ -11,6 +11,12 @@ object MHtmlHelpers {
       rx.impure.run({x => _value = Some(x)}).cancel
       _value.get
     }
+
+    def doOnce(fn: T => Unit) = rx.impure.run(fn).cancel
+  }
+
+  implicit class RxFlattener[T](outer: Rx[Rx[T]]) {
+    def flatten = outer.flatMap(identity)
   }
 
   implicit class UnprefixedAttributeEmbeddable[T](prev: UnprefixedAttribute[T])
@@ -23,13 +29,17 @@ object MHtmlHelpers {
       (implicit ev: XmlAttributeEmbeddable[T]) = {
       def replacePrevVal: PartialFunction[MetaData, MetaData] = {
         case UnprefixedAttribute(key, e, next) if key == attr => (value, e) match {
-          case (newFn: Function1[Event, Unit], prevFn: Function1[Event, Unit]) =>
+          case (newFn: Function1[Event @ unchecked, Unit @ unchecked],
+            prevFn: Function1[Event @ unchecked, Unit @ unchecked]) =>
             UnprefixedAttribute(key, { e: Event => prevFn(e); newFn(e) }, next)
-          case (newFn: Function0[Unit], prevFn: Function1[Event, Unit]) =>
+          case (newFn: Function0[Unit @ unchecked],
+            prevFn: Function1[Event @ unchecked, Unit @ unchecked]) =>
             UnprefixedAttribute(key, { e: Event => prevFn(e); newFn() }, next)
-          case (newFn: Function1[Event, Unit], prevFn: Function0[Unit]) =>
+          case (newFn: Function1[Event @ unchecked, Unit @ unchecked],
+            prevFn: Function0[Unit @ unchecked]) =>
             UnprefixedAttribute(key, { e: Event => prevFn(); newFn(e) }, next)
-          case (newFn: Function0[Unit], prevFn: Function0[Unit]) =>
+          case (newFn: Function0[Unit @ unchecked],
+            prevFn: Function0[Unit @ unchecked]) =>
             UnprefixedAttribute(key, { () => prevFn(); newFn() }, next)
           case (value, _) => UnprefixedAttribute(key, value, next)
         }
@@ -59,7 +69,7 @@ object MHtmlHelpers {
           UnprefixedAttribute("class", s"${e.data} $clss", next)
         case UnprefixedAttribute("class", e: String, next) =>
           UnprefixedAttribute("class", s"$e $clss", next)
-        case UnprefixedAttribute("class", e: Rx[String], next) =>
+        case UnprefixedAttribute("class", e: Rx[String @ unchecked], next) =>
           UnprefixedAttribute("class", e.map({x => s"$x $clss"}), next)
         //case UnprefixedAttribute("class", e: Rx[scala.xml.Text], next) =>
         //  UnprefixedAttribute("class", e.map({x => s"$x.data $clss"}), next)
@@ -81,7 +91,7 @@ object MHtmlHelpers {
           UnprefixedAttribute("class", rmClass(e.data), next)
         case UnprefixedAttribute("class", e: String, next) =>
           UnprefixedAttribute("class", rmClass(e), next)
-        case UnprefixedAttribute("class", e: Rx[String], next) =>
+        case UnprefixedAttribute("class", e: Rx[String @ unchecked], next) =>
           UnprefixedAttribute("class", e.map(rmClass), next)
         //case UnprefixedAttribute("class", e: Rx[scala.xml.Text], next) =>
         //  UnprefixedAttribute("class", e.map({x => rmClass(x.data)}), next)
@@ -95,17 +105,49 @@ object MHtmlHelpers {
         elm.minimizeEmpty, elm.child:_*)
     }
 
+    def beforeMount(fn: (HTMLElement => _)) = {
+      addAttribute("mhtml-onmount", {e: HTMLElement =>
+        fn(e)
+        ()
+      })
+    }
+
+    def afterMount(fn: (HTMLElement => _)) = {
+      addAttribute("mhtml-onmount", {e: HTMLElement =>
+        scala.scalajs.js.timers.setTimeout(0) {
+          fn(e)
+        }
+        ()
+      })
+    }
+
+    def beforeUnmount(fn: (HTMLElement => _)) = {
+      addAttribute("mhtml-onmount", {e: HTMLElement =>
+        fn(e)
+        ()
+      })
+    }
+
+    def afterUnmount(fn: (HTMLElement => _)) = {
+      addAttribute("mhtml-onmount", {e: HTMLElement =>
+        scala.scalajs.js.timers.setTimeout(0) {
+          fn(e)
+        }
+        ()
+      })
+    }
+
     def value(value: Rx[String]) = elm.label.toLowerCase match {
-      case "input" => addAttribute("mhtml-onmount", { e: Event =>
-        val input = e.target.asInstanceOf[HTMLInputElement]
+      case "input" => afterMount { elm =>
+        val input = elm.asInstanceOf[HTMLInputElement]
         value.map(input.value = _)
         ()
-      })
-      case "select" => addAttribute("mhtml-onmount", { e: Event =>
-        val input = e.target.asInstanceOf[HTMLSelectElement]
+      }
+      case "select" => afterMount { elm =>
+        val input = elm.asInstanceOf[HTMLSelectElement]
         value.map(input.value = _)
         ()
-      })
+      }
       case _ => ???
     }
 
@@ -115,13 +157,13 @@ object MHtmlHelpers {
       if (recs.length > 0)
         value := recs(0).target.asInstanceOf[HTMLInputElement].value
     }
+
     private def selectObserver(value: Var[String])
       (recs: js.Array[MutationRecord], obs: MutationObserver) = {
       if (recs.length > 0)
         value := recs(0).target.asInstanceOf[HTMLSelectElement].value
     }
     
-
     def value() = elm.label.toLowerCase match {
       case "input" => {
         val value = Var[String](getAttribute("value").getOrElse(""))
@@ -130,17 +172,17 @@ object MHtmlHelpers {
           val input = e.target.asInstanceOf[HTMLInputElement]
           value := input.value
           ()
-        }).addAttribute("mhtml-onmount", { input: HTMLInputElement =>
-          value := input.value
+        }).afterMount({ input =>
+          value := input.asInstanceOf[HTMLInputElement].value
           observer.observe(input, MutationObserverInit(
             attributes = true,
             attributeFilter = js.Array("value")
           ))
           ()
-        }).addAttribute("mhtml-onunmount", { input: HTMLSelectElement =>
+        }).beforeUnmount { input =>
           observer.disconnect()
           ()
-        }), value.impure.sharing)
+        }, value.impure.sharing)
       }
       case "select" => {
         // TODO: Get a more robust default value for select elements
@@ -148,9 +190,10 @@ object MHtmlHelpers {
         val observer = new MutationObserver(selectObserver(value))
         (addAttribute("onchange", { e: Event =>
           val input = e.target.asInstanceOf[HTMLSelectElement]
-          value := input.value
+          value := input.asInstanceOf[HTMLInputElement].value
           ()
-        }).addAttribute("mhtml-onmount", { input: HTMLSelectElement =>
+        }).afterMount({ input =>
+          value := input.asInstanceOf[HTMLSelectElement].value
           observer.observe(input, MutationObserverInit(
             attributes = true,
             attributeFilter = js.Array("value"),
@@ -158,12 +201,34 @@ object MHtmlHelpers {
             subtree = true
           ))
           ()
-        }).addAttribute("mhtml-onunmount", { input: HTMLSelectElement =>
+        }).beforeUnmount { input =>
           observer.disconnect()
           ()
-        }), value.impure.sharing)
+        }, value.impure.sharing)
       }
       case _ => ???
     }
+
+    def checked(value: Rx[Boolean]) = elm.label.toLowerCase match {
+      case "input" => afterMount { elm =>
+        val input = elm.asInstanceOf[HTMLInputElement]
+        value.map(input.checked = _)
+        ()
+      }
+      case _ => ???
+    }
+
+    def checked() = elm.label.toLowerCase match {
+      case "input" =>
+        val value = Var[Boolean](
+          elm.getAttribute("checked").map(_.toBoolean).getOrElse(false)
+        )
+        (addAttribute("onchange", { e: Event =>
+          val input = e.currentTarget.asInstanceOf[HTMLInputElement]
+          value.update(_ => input.checked)
+        }), value.impure.sharing)
+      case _ => ???
+    }
+
   }
 }

+ 16 - 0
client/src/main/scala/com/weEat/util/TimeoutHelper.scala

@@ -0,0 +1,16 @@
+package com.weEat.util
+
+import scala.scalajs.js.timers.setTimeout
+import scala.concurrent.duration.FiniteDuration
+import scala.concurrent.{ExecutionContext,Future,Promise}
+
+object TimeoutHelper {
+  def setTimeoutResult[T](delay: FiniteDuration)(body: => T)
+    (implicit ec: ExecutionContext) = {
+    val promise = Promise[T]()
+    setTimeout(delay) {
+      promise.success(body)
+    }
+    promise.future
+  }
+}

+ 5 - 0
client/src/main/scala/com/weEat/views/README.md

@@ -0,0 +1,5 @@
+# Views
+
+Each view represents a distinct page layout accessible from navigation.
+
+Make sure to add any new views to View.index.

+ 320 - 0
client/src/main/scala/com/weEat/views/RecipeEdit.scala

@@ -0,0 +1,320 @@
+package com.weEat.view
+
+import com.weEat.controllers.{FoodController,USDAController}
+import com.weEat.modules._
+import com.weEat.shared.models.UnitType._
+import com.weEat.shared.models._
+import com.weEat.util.MHtmlHelpers._
+import mhtml.{Rx,Var}
+import mhtml.future.syntax._
+import org.scalajs.dom.raw.{Event,KeyboardEvent,HTMLInputElement,HTMLElement}
+import scala.util.{Success,Failure}
+import mhtml.future.syntax._
+import com.tflucke.sortable.{Sortable, SortableOptions, SortableEvent}
+import scala.scalajs.js.timers.setTimeout
+import com.tflucke.typeahead._
+import com.tflucke.typeahead.Dataset.Templates
+import gov.usda.nal.fdc.models.SearchResultFood
+import org.scalajs.dom.document
+import gov.usda.nal.fdc.models.DataType._
+
+// TODO: prevent user from not having any of discreet/mass/volume input 
+// TODO: Save recipe node in cookie until ready to use
+object RecipeEdit extends View {
+  import com.weEat.Main.headers
+
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  val tag = "editRecipe"
+
+  val title = "Edit Recipe"
+
+  val ENTER_KEY_CODE = 13
+
+  private val (_nameIn, _name) =
+    <input id="name" class="form-control" required={true} />.value()
+
+  private val internalFoodDS = Dataset(
+    { _ => Nil},
+    Some({ str: String => FoodController.query(str)() }),
+    display = { food: FoodNodeId => food.name }
+  )
+
+  private val usdaFoodDS = Dataset(
+    { _ => Nil},
+    Some({ str: String =>
+      USDAController.getFoodsSearch(str, Seq(
+        Foundation, Survey, SRLegacy
+      ).map(_.toString))().map(_.foods.map(USDANodeNoId.fromSearchResult))
+    }),
+    templates = Some(Templates({x: USDANodeNoId => x.name}).copy(
+      header = Some({ (_: String, _: Seq[USDANodeNoId], _: String) =>
+        val html = document.createElement("div").asInstanceOf[HTMLElement]
+        html.innerText = "Unoffical Foods"
+        html
+      })
+    )),
+    display = {x: USDANodeNoId => x.name}
+  )
+
+  private val _ingredientSearch =
+    <input type="text" class="form-control input-sm" />.afterMount { elm =>
+      TypeaheadElement[FoodNode](
+        elm.asInstanceOf[HTMLInputElement],
+        minLength = 3
+      )(Seq(internalFoodDS, usdaFoodDS))
+      elm.addEventListener("typeahead:selected", {e: CursorEvent[FoodNode] =>
+        e.selectable.map(_.data).foreach({ node =>
+          // TODO: default unit
+          _editIngredient(Ingredient.fromFoodNode(node, 0, Gram)) { in =>
+            _ingredients.update(_ :+ in)
+          }
+        })
+      })
+    }
+
+  private val _stepIn = <input id="step" class="form-control"
+    onkeypress={ e: KeyboardEvent => if (e.keyCode == ENTER_KEY_CODE) {
+      val elm = e.target.asInstanceOf[HTMLInputElement]
+      _steps.update(_ :+ elm.value)
+      elm.value = ""
+    } } />
+
+  private val _ingredients = Var[Seq[Ingredient]](Nil)
+  private val _steps = Var[Seq[String]](Nil)
+
+  private val (_servingsIn, _servings) =
+    <input id="serv" type="number" class="form-control" value="4" step=".1"
+           required={true} />.value()
+
+  private val (_discreetIn, _discreet) =
+    <input id="discreet" type="checkbox" class="form-check-input" checked={true} />
+      .checked()
+
+  // TODO: Try autopopulate ingredients.sum(_.massPerUnit)
+  private val (_massPIn, _massP) =
+    <input id="massP" type="number" min="1" class="form-control" step=".1" />
+      .value()
+
+  // TODO: Try autopopulate ingredients.averge(_.density)
+  private val (_volPIn, _volP) =
+    <input id="volP" type="number" min="0" class="form-control col-6" step=".1" />
+      .value()
+
+  private val (_volPUnitIn, _volPUnit) =
+    <select class="custom-select col-6">
+    { MeasureUnit.units.zipWithIndex
+      .filter({ case (u, _) => u.typ == VOLUME })
+      .map({ case (unit, idx) =>
+        // TODO: default selected dynamic
+        <option value={idx.toString} selected={unit.abr == "mL"}>
+        {unit.name}
+        </option>
+      })
+    }
+    </select>.value()
+
+  val recipieNode = _ingredients.zip(_steps)
+    .zip(_name)
+    .zip(_discreet)
+    .zip(_servings)
+    .zip(_volP)
+    .zip(_volPUnit)
+    .zip(_massP)
+    .map({
+      case (((((((
+        ingredients,
+        steps),
+        name),
+        discreet),
+        servings),
+        volP),
+        volPUnit),
+        massP) =>
+        val gPServ = massP.toFloatOption
+        val mLPServ = volP.toFloatOption
+          .map( _ * MeasureUnit(volPUnit.toInt).conversionRatio )
+        val (servFactor, servUnit) = gPServ.zip(Some(Gram))
+          .getOrElse(
+            if (discreet) (1.0f, Count)
+            else mLPServ.zip(Some(Milliliter)).getOrElse(???)
+          ).asInstanceOf[(Float, MeasureUnit)]
+        val numServings = servings.toFloatOption.getOrElse(1.0f)
+        val stdQtiesPServing = servFactor / servUnit.typ.standardQuanity
+        val stdQties = numServings * stdQtiesPServing
+
+        RecipeNodeNoId(
+          name,
+          stdQties,
+          stdQtiesPServing,
+          servUnit.typ,
+          ingredients,
+          steps,
+          gPServ.zip(mLPServ).map({ case (m, v) => (m/v).toFloat }),
+          gPServ
+        )
+    })
+
+  private def _editIngredient(ing: Ingredient)(callback: (Ingredient => _)) = {
+    Overlay.loading(ing.food.map({ food =>
+      val (amountIn, amount) = <input type="number" min="0"
+      class="form-control input-sm col-9"
+      value={ing.amount.toString} />.value()
+      val (idxIn, idx) = <select class="col-3 custom-select">
+      { MeasureUnit.units.zipWithIndex
+        .map({ case (unit, idx) =>
+          // TODO: default selected dynamic
+          <option value={idx.toString} selected={unit.abr == "g"}>
+          {unit.name}
+          </option>
+        })
+      }
+      </select>.value()
+      Overlay.confirm(Rx(<div class="row">{amountIn}{idxIn}</div>)) { () =>
+        callback(ing.copy(
+          amount = amount.value.toFloat,
+          unit = MeasureUnit.units(idx.value.toInt)
+        ))
+      }
+    }))
+  }
+
+  private def _removeFromList[T](s: Seq[T], idx: Int) =
+    s.take(idx) ++ s.drop(idx + 1)
+
+  private val _stepList =
+    <ol style="list-style-type: none; padding-left: 0;">
+      { _steps.map(_.zipWithIndex.map((presentStep _).tupled)) }
+    </ol>.afterMount(Sortable.create(_, SortableOptions.onUpdate({
+      event: SortableEvent => 
+        _steps.update({ steps =>
+          (event.oldIndex.toOption, event.newIndex.toOption) match {
+            case (Some(old), Some(ne)) if (old < ne) =>
+              moveBackwards(steps, old, ne)
+            case (Some(old), Some(ne)) =>
+              moveBackwards(
+                steps.reverse,
+                steps.size - old - 1,
+                steps.size - ne - 1
+              ).reverse
+            case (None, Some(ne)) =>
+              val (first, second) = steps.splitAt(ne)
+              (first :+ event.item.innerText) ++ second
+            case (Some(old), None) =>
+              _removeFromList(steps, old)
+          }
+        })
+        // If the added element is still in the parent, remove it since the
+        // Rx.update will have generated a new one.
+        Option(event.item.parentElement).map(_.removeChild(event.item))
+
+        def moveBackwards(steps: Seq[String], old: Int, ne: Int) =
+          (steps.take(old) ++
+            steps.take(ne+1).drop(old + 1) :+
+            steps(old)) ++
+            steps.drop(ne+1)
+      })))
+
+  def presentFoodNode(ingredient: Ingredient, idx: Int) = {
+    // TODO: Convert to user preferred units
+    ingredient.food.toRx.map({
+      case Some(Success(food)) =>
+        <li>
+          <span class="ui-icon ui-icon-pencil" onclick={ e: Event =>
+            _editIngredient(ingredient) { in =>
+              _ingredients.update(_.updated(idx, in))
+            }
+            ()
+          }></span>
+          <span class="ui-icon ui-icon-close" onclick={ e: Event =>
+            _ingredients.update(_.filterNot(_ == ingredient))
+          }></span>
+          {f"${ingredient.amount}%.0f${ingredient.unit.abr} ${food.name}"}
+        </li>
+      case None => <li></li>
+      case x => {println(x);???}
+    })
+  }
+
+  def presentStep(step: String, idx: Int) = {
+    <li>
+      <span class="ui-icon ui-icon-close" onclick={ e: Event =>
+        _steps.update(_removeFromList(_, idx))
+      }></span>
+      {step}
+    </li>
+  }
+
+  def content = {
+    <div>
+    <h2>Recipe Editor</h2>
+    <div class="form-group">
+      <div class="container">
+        <div class="row">
+          <div class="col-md-12">
+            <label>Name: </label>{ _nameIn }
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-md-3">
+            <div class="form-check">
+              { _discreetIn }
+              <label class="form-check-label" for="discreet">
+                Descrete Servings
+              </label>
+            </div>
+          </div>
+          <div class="col-md-3">
+            <label># Servings: </label>
+            { _servingsIn }
+          </div>
+          <div class="col-md-3">
+            <label>Grams/Serving: </label>
+            { _massPIn }
+          </div>
+          <div class="col-md-3">
+            <div class="container">
+              <label>Volume/Serving: </label>
+              <div class="row">
+                { _volPIn } { _volPUnitIn }
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-md-5">
+            <h2>Ingredients</h2>
+            { _ingredientSearch }
+            <ul style="list-style-type: none; padding-left: 0;">
+              { _ingredients.map(_.zipWithIndex.map((presentFoodNode _).tupled)
+                .foldRight(Rx(Seq[scala.xml.Elem]()))(for {n<-_; s<-_} yield n+:s)
+              ).flatten }
+            </ul>
+          </div>
+          <div class="col-md-5">
+            <h2>Steps</h2>
+           { _stepIn }
+           { _stepList }
+          </div>
+          <div class="col-md-2">
+            { NutritionPane(recipieNode).render }
+          </div>
+        </div>
+      </div>
+      <button class="btn" onclick={ e: Event =>
+        import play.api.libs.json.Json
+        println(recipieNode.value)
+        println(Json.toJson(recipieNode.value))
+        println(Json.stringify(Json.toJson(recipieNode.value)))
+        FoodController.add()(recipieNode.value).onComplete {
+          case Success(_) => println("Success!")
+          case Failure(ex) =>
+            println("Could not add recipe")
+            throw ex
+        }
+        ()
+      }>Add</button>
+    </div>
+    </div>
+  }
+}

+ 6 - 5
client/src/main/scala/com/weEat/views/UsdaImporter.scala

@@ -3,7 +3,7 @@ package com.weEat.view
 import com.weEat.controllers.{FoodController,USDAController}
 import com.weEat.modules._
 import com.weEat.shared.models.UnitType._
-import com.weEat.shared.models.USDANode
+import com.weEat.shared.models.USDANodeNoId
 import com.weEat.util.MHtmlHelpers._
 import gov.usda.nal.fdc.models._
 import gov.usda.nal.fdc.models.DataType._
@@ -21,6 +21,8 @@ object UsdaImporter extends View {
 
   val title = "USDA Import"
 
+  override val permissions = Set("admin")
+
   def content = {
     val searchBar = SearchBar(term => USDAController.getFoodsSearch(term, Seq(
       Foundation, Survey, SRLegacy
@@ -58,12 +60,11 @@ object UsdaImporter extends View {
     {
       PaginatedTable[SearchResultFood](Seq(
         ("", 1, {x => Rx(<button class="btn btn-light" onclick={e: Event =>
-          // defaultUnit = if (num().nonEmpty) NUMBER else MASS\
-          val editor = USDAEditor(USDANode.fromSearchResult(x), true)
+          // defaultUnit = if (num().nonEmpty) NUMBER else MASS
+          val editor = USDAEditor(USDANodeNoId.fromSearchResult(x), true)
           Overlay.confirm(Rx(editor.render)) { () =>
             import com.weEat.Main.headers
-            println(editor.getUSDANode(None))
-            FoodController.add()(editor.getUSDANode(None))
+            FoodController.add()(editor.getUSDANode())
           }
           ()
         }>Add</button>)}),

+ 31 - 0
client/src/main/scala/com/weEat/views/UserManage.scala

@@ -0,0 +1,31 @@
+package com.weEat.view
+
+import com.weEat.controllers.{FoodController,USDAController}
+import com.weEat.modules._
+import com.weEat.shared.models.UnitType._
+import com.weEat.shared.models.USDANodeNoId
+import com.weEat.util.MHtmlHelpers._
+import gov.usda.nal.fdc.models._
+import gov.usda.nal.fdc.models.DataType._
+import mhtml.Rx
+import mhtml.future.syntax._
+import org.scalajs.dom.raw.Event
+import scala.util.Success
+
+object UserManage extends View {
+  import com.weEat.Main.headers
+
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  val tag = "umanage"
+
+  val title = "Manage Users"
+
+  override val permissions = Set("admin")
+
+  def content = {
+    <div>
+    <h2>Manage Users</h2>
+    </div>
+  }
+}

+ 22 - 5
client/src/main/scala/com/weEat/views/View.scala

@@ -1,28 +1,45 @@
 package com.weEat.view
 
+import com.weEat.OAuthManager
 import org.scalajs.dom.document
+import scala.util.{Try,Success}
 
 trait View {
   def tag: String
   def title: String
   def content: scala.xml.Node
+  def permissions: Set[String] = Set.empty
 
-  def present = {
+  def present() = {
     document.title =
       document.title.split(View.titleSeperator)(0) + View.titleSeperator + title
+    val children = View._contentDiv.children
+    for (i <- (0 to children.length - 1)) {
+      View._contentDiv.removeChild(children(i))
+    }
     mhtml.mount(View._contentDiv, content)
   }
+
+  def init(args: Map[String, String]): Try[Unit] = Success(())
+  def dispose(): Unit = ()
 }
 
 object View {
   private val _contentDiv = document.getElementById("content")
 
-  val home = UsdaImporter
+  def home = RecipeEdit
   val titleSeperator = " - "
-  val views = Seq(
-    UsdaImporter
+
+  def index = Seq(
+    UsdaImporter,
+    UserManage,
+    RecipeEdit
+  )
+
+  def authedIndex = OAuthManager.isAuthedFor("admin").map(isAdmin =>
+    if (isAdmin) index else index.filterNot(_.permissions.contains("admin"))
   )
 
-  def fromTagOpt(tag: String) = views.find(_.tag == tag)
+  def fromTagOpt(tag: String) = index.find(_.tag == tag)
   def fromTag(tag: String) = fromTagOpt(tag).getOrElse(home)
 }

+ 2 - 1
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResult.scala

@@ -9,5 +9,6 @@ case class SearchResult(
 )
 
 object SearchResult {
-  implicit val fmt = play.api.libs.json.Json.format[SearchResult]
+  import play.api.libs.json.Json
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[SearchResult]
 }

+ 4 - 3
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResultFood.scala

@@ -8,7 +8,7 @@ case class SearchResultFood(
   val dataType: DataType,
   val description: String,
   val lowercaseDescription: String,
-  val foodCode: Option[String],
+  val foodCode: Option[Long] = None,
   val foodNutrients: Seq[AbridgedFoodNutrient],
   val publicationDate: Option[Date],
   val scientificName: Option[String],
@@ -20,10 +20,11 @@ case class SearchResultFood(
   val gtinUpc: Option[String],
   val ingredients: Option[String],
   // Foundation SRLegacy
-  val ndbNumber: Option[String]
+  val ndbNumber: Option[Long]
 )
 
 object SearchResultFood {
   import play.api.libs.functional.syntax._
-  implicit val fmt = play.api.libs.json.Json.format[SearchResultFood]
+  import play.api.libs.json.Json
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[SearchResultFood]
 }

+ 91 - 8
server/app/com/weEat/controllers/FoodController.scala

@@ -10,14 +10,20 @@ import play.api.libs.json._
 import play.api.mvc._
 import org.bson.types.ObjectId
 import org.mongodb.scala.model.Filters._
+import org.mongodb.scala.model.Updates._
 import scala.concurrent.{ExecutionContext,Future}
 import scala.util.{Success,Failure}
+import com.weEat.models.Authorization
+import scalaoauth2.provider.{AuthInfoRequest,OAuth2ProviderActionBuilders}
+import com.weEat.services.OAuth2Service
 
 @Singleton
 class FoodController @Inject()(
   val controllerComponents: ControllerComponents,
+  oauth: OAuth2Service,
   db: MongoDBService
-) extends BaseController {
+) extends BaseController
+    with OAuth2ProviderActionBuilders {
   implicit val ec = scala.concurrent.ExecutionContext.global
 
   import db.withCollection
@@ -35,7 +41,7 @@ class FoodController @Inject()(
     }.flatten
   }
 
-  def query() = Action.async
+  def all() = Action.async
   { implicit request: Request[AnyContent] =>
     withCollection(FoodNodeCollection) {collection =>
       collection.find()
@@ -47,14 +53,91 @@ class FoodController @Inject()(
     }.flatten
   }
 
-  def add() = Action.async(parse.json)
-  { implicit request: Request[JsValue] =>
+  def query(q: String) = Action.async
+  { implicit request: Request[AnyContent] =>
+    withCollection(FoodNodeCollection) {collection =>
+      collection.find(regex("name", s".*$q.*"))
+        .toFuture()
+        .transform({
+          case Success(x) => Success(Ok(Json.toJson(x)))
+          case Failure(x) => throw x
+        })
+    }.flatten
+  }
+
+  // def getImage(foodId: String, idx: Int) = Action.async
+  // { implicit request: Request[AnyContent] =>
+  //   withCollection(FoodImages) {collection =>
+  //     collection.find(and(equal("food", foodId), equal("index", idx)))
+  //       .first()
+  //       .toFuture()
+  //       .transform({
+  //         case Success(img) =>
+  //           Success(Ok.streamed(img.data, img.data.length, img.mime))
+  //         case Failure(x) => throw x
+  //       })
+  //   }.flatten
+  //   ???
+  // }
+
+  // def addImageTo(id: String) = Action.async(parse.byteString)
+  // { implicit request: Request[ByteString] =>
+    // withCollection(FoodImages) { images =>
+    //   images.insertOne(FoodImage(None, id, , request.body, request.contentType.get))
+    //     .head().transformWith({
+    //       case Success(img) => withCollection(FoodNode) { foods =>
+    //         foods.findOneAndUpdate(
+    //           equal("id", id),
+    //           push("imageIds", img._id.get)
+    //         ).head().transform({
+    //           case Success(x) => Success(Ok(x))
+    //           case Failure(x) =>
+    //             images.deleteOne(eq("_id", img._id.get))
+    //             throw x
+    //         })
+    //       }
+    //       case Failure(x) => throw x
+    //     })
+    // }.flatten
+
+  //   ???
+  // }
+
+  // def deleteImage(foodId: String, imageId: String) = Action.async
+  // { implicit request: Request[AnyContent] =>
+  //   withCollection(FoodNode) { foods =>
+  //     foods.findOneAndUpdate(
+  //       equal("id", foodId),
+  //       pull("imageIds", imageId)
+  //     ).head().transformWith({
+  //       case Success(x) => images.deleteOne(eq("_id", img._id.get))
+  //           .head().transform({
+  //             case Success(x) => Success(Ok(x))
+  //             case Failure(x) => throw x
+  //           })
+  //       case Failure(x) => throw x
+  //     })
+  //   }.flatten
+
+  //   ???
+  // }
+
+  def add(uid: Option[String]) = AuthorizedAction[Authorization](oauth)
+    .async(parse.json)
+  { implicit request: AuthInfoRequest[JsValue, Authorization] =>
     try {
-      val food = request.body.as[FoodNode].withId(new ObjectId)
+      val food = request.body.as[FoodNode].withId(
+        new ObjectId,
+        uid.flatMap({ id =>
+          if (request.authInfo.user.hasAdminPermissions) Some(new ObjectId(id))
+          else None
+        }).getOrElse(request.authInfo.user.userId)
+      )
       withCollection(FoodNodeCollection) {collection =>
-        collection.insertOne(food).map(res =>
-          Ok(Json.toJson(food.withId(res.getInsertedId().asObjectId().getValue())))
-        ).head()
+        collection.insertOne(food).map({ res =>
+          val id = res.getInsertedId().asObjectId().getValue()
+          Ok(Json.toJson(food.withId(id)))
+        }).head()
       }.flatten
     }
     catch {

+ 12 - 6
server/app/com/weEat/controllers/USDAController.scala

@@ -59,18 +59,24 @@ class USDAController @Inject()(
       sortBy.map(x => SortCriteria.withName(x)),
       sortOrder.map(x => SortOrder.withName(x.toLowerCase))
     )().transform({
-      case Success(x) => Success(Ok(Json.toJson(x)))
-      case Failure(x: HTTPException) => Success((new Status(x.statusCode))(x.responseText))
-      case Failure(x) => Success(InternalServerError(x.toString))
+      case Success(x) =>
+        Success(Ok(Json.toJson(x)))
+      case Failure(x: HTTPException) =>
+        Success((new Status(x.statusCode))(x.responseText))
+      case Failure(x) =>
+        Success(InternalServerError(x.toString))
     })
   }
 
   def postFoodsSearch() = Action.async(parse.json) {
     implicit request: Request[JsValue] =>
     fdc.postFoodsSearch()(request.body.as[FoodSearchCriteria]).transform({
-      case Success(x) => Success(Ok(Json.toJson(x)))
-      case Failure(x: HTTPException) => Success((new Status(x.statusCode))(x.responseText))
-      case Failure(x) => Success(InternalServerError(x.toString))
+      case Success(x) =>
+        Success(Ok(Json.toJson(x)))
+      case Failure(x: HTTPException) =>
+        Success((new Status(x.statusCode))(x.responseText))
+      case Failure(x) =>
+        Success(InternalServerError(x.toString))
     })
   }
 

+ 53 - 2
server/app/com/weEat/controllers/ViewController.scala

@@ -3,6 +3,13 @@ package com.weEat.controllers
 import javax.inject._
 import play.api._
 import play.api.mvc._
+import com.weEat.services.MongoDBService
+import play.api.libs.json.JsValue
+import com.weEat.models.User
+import com.weEat.shared.models.UserRegistration
+import scala.util.{Try,Success,Failure}
+import scala.concurrent.Future
+import org.bson.types.ObjectId
 
 /**
  * This controller creates an `Action` to handle HTTP requests to the
@@ -10,12 +17,56 @@ import play.api.mvc._
  */
 @Singleton
 class ViewController @Inject()(
-  val controllerComponents: ControllerComponents
+  val controllerComponents: ControllerComponents,
+  db: MongoDBService
 ) (implicit
   val webJarsUtil: org.webjars.play.WebJarsUtil
 ) extends BaseController {
 
+  implicit val ec = scala.concurrent.ExecutionContext.global
+
+  private var initalized = false
+
+  def initalizer() = Action.async { implicit request: Request[AnyContent] =>
+    db.withCollection(User) { users =>
+      import org.mongodb.scala.model.Filters._
+      users.find(equal("isAdmin", true)).head()
+    }.flatten.map {
+      case null => Ok(views.html.initalizer())
+      case _ =>
+        initalized = true;
+        MovedPermanently(routes.ViewController.loader().url)
+    }
+  }
+
+  // Basically everything about this function is a special case.
+  // I don't like it, but it is unique among every other bit of functionality.
+  def initalize() = Action.async(parse.formUrlEncoded) {
+    implicit request: Request[Map[String, Seq[String]]] =>
+    db.withCollection(User) { users =>
+      import org.mongodb.scala.model.Filters._
+      import com.github.t3hnar.bcrypt.BCryptStrOps
+      users.find(equal("isAdmin", true)).head().flatMap {
+        case null => users.insertOne(User(
+          new ObjectId,
+          request.body("fname")(0),
+          request.body("lname")(0),
+          request.body("email")(0),
+          request.body("password")(0).boundedBcrypt,
+          isAdmin = true
+        )).head()
+        case existingAdmin => Future.successful(existingAdmin)
+      }
+    }.flatten.map({ _ =>
+      initalized = true;
+      MovedPermanently(routes.ViewController.loader().url)
+    })
+  }
+
   def loader() = Action { implicit request: Request[AnyContent] =>
-    Ok(views.html.viewLoader())
+    if (initalized)
+      Ok(views.html.viewLoader())
+    else
+      TemporaryRedirect(routes.ViewController.initalizer().url)
   }
 }

+ 3 - 7
server/app/com/weEat/migrations/InitDb.scala

@@ -2,7 +2,7 @@ package com.weEat.migrations
 
 import org.mongodb.scala.MongoDatabase
 import com.weEat.models.{Authorization,User}
-import com.weEat.shared.models.FoodNode
+import com.weEat.shared.models.FoodNodeId
 import com.mongodb.client.model._
 import org.mongodb.scala.model.Filters._
 import org.mongodb.scala.model.Indexes._
@@ -38,12 +38,8 @@ object InitDb extends Migration {
   }
 
   def createFoodCollection(db: MongoDatabase) =
-    db.createCollection(FoodNode.collectionName,
-      new CreateCollectionOptions().validationOptions(
-        new ValidationOptions().validator(
-          typ("defaultUnit", STRING)
-        )
-      )
+    db.createCollection(FoodNodeId.collectionName,
+      new CreateCollectionOptions()
     ).head()
 
   def createUserCollection(db: MongoDatabase) =

+ 1 - 1
server/app/com/weEat/migrations/Migration.scala

@@ -17,7 +17,7 @@ object Migration {
     InitDb,
     SeedNutrition,
     RestoreFromFile(Source.fromResource("db-seeds/nutrients.json"), Nutrient)
-    //RestoreFromFile(Source.fromResource("db-seeds/usda-foods.json"), FoodNode)
+    //RestoreFromFile(Source.fromResource("db-seeds/usda-foods.json"), FoodNode),
   )
 
   def executeAll(db: MongoDatabase) = executeFutures(db, migrations)

+ 15 - 0
server/app/com/weEat/models/FoodImages.scala

@@ -0,0 +1,15 @@
+package com.weEat.models
+
+import org.bson.types.ObjectId
+
+case class FoodImage (
+  val _id: Option[ObjectId],
+  val food: ObjectId,
+  val index: Int,
+  val data: Array[Byte],
+  val mime: String
+)
+
+object FoodImage extends Collectable[FoodImage] {
+  val collectionName = "food_images"
+}

+ 1 - 1
server/app/com/weEat/models/FoodNode.scala

@@ -1,5 +1,5 @@
 package com.weEat.models
 
-object FoodNode extends Collectable[com.weEat.shared.models.FoodNode] {
+object FoodNode extends Collectable[com.weEat.shared.models.FoodNodeId] {
   val collectionName = "foods"
 }

+ 16 - 7
server/app/com/weEat/models/User.scala

@@ -17,9 +17,11 @@ case class User (
   val lname: String,
   val email: String,
   val password: String,
-  val created: Instant = Instant.now()
+  val created: Instant = Instant.now(),
+  val isAdmin: Boolean = false
 ) {
   def toShared() = com.weEat.shared.models.User(
+    _id,
     fname,
     lname,
     email
@@ -46,7 +48,8 @@ class Authorization (
   val refreshToken: Array[Byte],
   val created: Instant,
   val email: String,
-  val userId: ObjectId
+  val userId: ObjectId,
+  val hasAdminPermissions: Boolean
 ) {
   def accessExpiration() = created + Authorization.accessFreshTime
   def refreshExpiration() = created + Authorization.refreshFreshTime
@@ -60,7 +63,9 @@ class Authorization (
   def toToken() = new AccessToken(
     Authorization.encodeToken(accessToken),
     Some(Authorization.encodeToken(refreshToken)),
-    None,
+    Some(Set.concat(
+      Option.when(hasAdminPermissions)("admin")
+    ).mkString(" ")),
     Some(Duration.between(Instant.now(), accessExpiration()).getSeconds()),
     created
   )
@@ -69,7 +74,10 @@ class Authorization (
     Authorization.encodeToken(accessToken),
     "Bearer",
     Duration.between(Instant.now(), accessExpiration()),
-    Authorization.encodeToken(refreshToken)
+    Authorization.encodeToken(refreshToken),
+    Set.concat(
+      Option.when(hasAdminPermissions)("admin")
+    )
   )
 }
 
@@ -90,11 +98,12 @@ object Authorization extends Collectable[Authorization] {
   def encodeToken(token: Array[Byte]) = Base64.getEncoder.encodeToString(token)
   def decodeToken(token: String) = Base64.getDecoder.decode(token)
 
-  def apply(id: ObjectId, email: String) = new Authorization(
+  def apply(user: User) = new Authorization(
     generateSecureBytes(),
     generateSecureBytes(),
     Instant.now(),
-    email,
-    id
+    user.email,
+    user._id,
+    user.isAdmin
   )
 }

+ 84 - 9
server/app/com/weEat/services/MongoDBService.scala

@@ -3,12 +3,14 @@ package com.weEat.services
 import play.api.Configuration
 import org.mongodb.scala.{MongoClient,MongoDatabase,MongoCollection}
 import org.mongodb.scala.bson.codecs.Macros._
+import org.mongodb.scala.bson.codecs._
 import org.bson.{BsonReader,BsonWriter}
 import org.bson.codecs.{Codec,DecoderContext,EncoderContext}
 import org.bson.codecs.configuration.{CodecProvider,CodecRegistry}
-import org.bson.codecs.configuration.CodecRegistries.{fromRegistries,fromProviders}
+import org.bson.codecs.configuration.CodecRegistries._
 import com.weEat.models._
-import com.weEat.shared.models.{USDANode,RecipeNode,FoodNode,UnitType}
+import com.weEat.shared.models.{FoodNodeId,USDANodeId,Ingredient,UnitType,MeasureUnit,RecipeNodeId}
+import Ingredient._
 import javax.inject.{Inject,Singleton}
 import scala.reflect.ClassTag
 import com.weEat.migrations.{Migration,Metadata}
@@ -30,13 +32,27 @@ class MongoDBService @Inject()(config: Configuration) {
   val port = config.getOptional[Int](s"$prefix.port").getOrElse(27017)
 
   val codecRegistry = fromRegistries(
-    fromProviders(classOf[Metadata]),
-    fromProviders(classOf[FoodNode]),
-    fromProviders(classOf[User]),
-    fromProviders(classOf[Authorization]),
-    fromProviders(classOf[Nutrient]),
-    //fromProviders(classOf[UnitType.Value]),
-    fromProviders(UnitTypeEnumCodecProvider),
+    fromProviders(
+      WrapperCodecProvider(classOf[FoodNodeId],
+        { n: USDANodeId =>
+          n.copy(nutrients = n.nutrients.map {
+            case (k, v) => (k.replace(".", "$"), v)
+          })
+        }, { n: USDANodeId =>
+          n.copy(nutrients = n.nutrients.map {
+            case (k, v) => (k.replace("$", "."), v)
+          })
+        }),
+      classOf[Metadata],
+      classOf[IngredientId],
+      classOf[Ingredient],
+      classOf[FoodNodeId],
+      classOf[User],
+      classOf[Authorization],
+      classOf[Nutrient],
+      UnitTypeEnumCodecProvider,
+      MeasureUnitCodecProvider
+    ),
     MongoClient.DEFAULT_CODEC_REGISTRY
   )
 
@@ -75,6 +91,39 @@ class MongoDBService @Inject()(config: Configuration) {
     })
 }
 
+case class WrapperCodecProvider[T](
+  provider: CodecProvider,
+  encodeFn: (T => T),
+  decodeFn: (T => T)
+)(implicit m: ClassTag[T]) extends CodecProvider {
+
+  override def get[U](c: Class[U], registry: CodecRegistry): Codec[U] =
+    if (m.runtimeClass.isAssignableFrom(c) && c.isAssignableFrom(m.runtimeClass))
+      WrapperCodec(provider.get(c, registry))(
+        encodeFn.asInstanceOf[U => U],
+        decodeFn.asInstanceOf[U => U]
+      ).asInstanceOf[Codec[U]]
+    else null
+}
+
+case class WrapperCodec[T](val inner: Codec[T])(
+  encodeFn: (T => T),
+  decodeFn: (T => T)
+) extends Codec[T] {
+  override def decode(
+    reader: BsonReader,
+    decoderContext: DecoderContext
+  ): T = decodeFn(inner.decode(reader, decoderContext))
+
+  override def encode(
+    writer: BsonWriter,
+    value: T,
+    encoderContext: EncoderContext
+  ): Unit = inner.encode(writer, encodeFn(value), encoderContext)
+
+  override def getEncoderClass: Class[T] = inner.getEncoderClass
+}
+
 object UnitTypeEnumCodecProvider extends CodecProvider {
 
   def isCaseObjectEnum[T](clazz: Class[T]): Boolean =
@@ -102,3 +151,29 @@ object UnitTypeEnumCodecProvider extends CodecProvider {
       UnitType.getClass.asInstanceOf[Class[UnitType.UnitType]]
   }
 }
+
+object MeasureUnitCodecProvider extends CodecProvider {
+
+  def isCaseObjectEnum[T](clazz: Class[T]): Boolean =
+    classOf[MeasureUnit].isAssignableFrom(clazz)
+
+  override def get[T](clazz: Class[T], registry: CodecRegistry): Codec[T] =
+    if (isCaseObjectEnum(clazz)) MeasureUnitCodec.asInstanceOf[Codec[T]]
+    else null
+
+  object MeasureUnitCodec extends Codec[MeasureUnit] {
+    override def decode(
+      reader: BsonReader,
+      decoderContext: DecoderContext
+    ): MeasureUnit = MeasureUnit.fromString(reader.readString()).get
+
+    override def encode(
+      writer: BsonWriter,
+      value: MeasureUnit,
+      encoderContext: EncoderContext
+    ): Unit = writer.writeString(value.toString)
+
+    override def getEncoderClass: Class[MeasureUnit] =
+      MeasureUnit.getClass.asInstanceOf[Class[MeasureUnit]]
+  }
+}

+ 17 - 9
server/app/com/weEat/services/OAuth2Service.scala

@@ -1,5 +1,6 @@
 package com.weEat.services
 
+import play.api.Configuration
 import codes.reactive.scalatime._
 import com.github.t3hnar.bcrypt._
 import com.weEat.models.{User,Authorization}
@@ -15,10 +16,11 @@ import org.mongodb.scala.model.Updates._
 import org.mongodb.scala.model.Filters._
 import org.mongodb.scala.bson.{BsonArray,BsonDocument,BsonValue}
 import org.mongodb.scala.bson.DefaultBsonTransformers
+import org.mongodb.scala.bson.conversions.Bson
 import java.util.Date
 
 @Singleton
-class OAuth2Service @Inject()(db: MongoDBService)
+class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
     extends AuthorizationHandler[User]
     with ProtectedResourceHandler[Authorization]
     with DefaultBsonTransformers {
@@ -29,12 +31,14 @@ class OAuth2Service @Inject()(db: MongoDBService)
 
   override def createAccessToken(auth: AuthInfo[User]) = {
     println("Create token")
-    val newAuth = Authorization(auth.user._id, auth.user.email)
+    val newAuth = Authorization(auth.user)
     withCollection(Authorization) {auths =>
       auths.insertOne(newAuth).head().map(_ => newAuth.toToken())
     }.flatten
   }
 
+  val devMode = config.getOptional[Boolean](s"oauth.dev").getOrElse(false)
+
   override def validateClient(
     cred: Option[ClientCredential],
     requ: AuthorizationRequest
@@ -53,13 +57,16 @@ class OAuth2Service @Inject()(db: MongoDBService)
       case OAuthGrantType.CLIENT_CREDENTIALS => throw new UnsupportedGrantType()
       case OAuthGrantType.IMPLICIT => throw new UnsupportedGrantType()
     }
-  }.map({x => {println(s"validateClient: $x");x}})
+  }
+
+  private def withDevModeCond(bson: Bson) =
+    if (devMode) bson else and(bson, not(equal("devOnly", true)))
 
   private def validateUsernamePassword(
     username: String,
     pass: String
   ): Future[Boolean] = withCollection(User) {users =>
-    users.find(equal("email", username))
+    users.find(withDevModeCond(equal("email", username)))
       .first()
       .toFutureOption()
       .map(res => res.map { x => pass.isBcryptedSafeBounded(x.password)}
@@ -79,9 +86,9 @@ class OAuth2Service @Inject()(db: MongoDBService)
     cred: Option[ClientCredential],
     requ: AuthorizationRequest
   ): Future[Option[User]] = withCollection(User) {users =>
-    users.find(equal("email", cred.getOrElse(
+    users.find(withDevModeCond(equal("email", cred.getOrElse(
       throw new UnsupportedGrantType("client_id required")
-    ).clientId)).first().toFutureOption()
+    ).clientId))).first().toFutureOption()
   }.flatten.map({x => {println(s"findUser: $x"); x}})
   
   /* 2020-07-25: Never re-issue the same authorization token. Always generate a
@@ -126,8 +133,9 @@ class OAuth2Service @Inject()(db: MongoDBService)
         .toFutureOption()
     }.flatten
       .map(authOpt => withCollection(User) {users =>
-        optFut2FutOpt(authOpt.map({ auth => users.find(equal("_id", auth.userId))
-          .first()
+        optFut2FutOpt(authOpt.map({ auth => users.find(
+          withDevModeCond(equal("_id", auth.userId))
+        ).first()
           .toFuture()
           .map({user => AuthInfo(user, Some(user.email), None, None)})
         }))
@@ -137,7 +145,7 @@ class OAuth2Service @Inject()(db: MongoDBService)
     auth: AuthInfo[User],
     refreshToken: String
   ): Future[AccessToken] = {
-    val newAuth = Authorization(auth.user._id, auth.user.email)
+    val newAuth = Authorization(auth.user)
     withCollection(Authorization) {auths =>
       auths.replaceOne(
         and(

+ 46 - 0
server/app/views/initalizer.scala.html

@@ -0,0 +1,46 @@
+@()(implicit webJarsUtil: org.webjars.play.WebJarsUtil, request: RequestHeader)
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Open Sauce - Setup</title>
+  <link rel="shortcut icon" type="image/png"
+	    href="@routes.Assets.versioned("images/favicon.png")">
+</head>
+<body>
+  <h1>Open Source Setup</h1>
+  <p>
+    Before the website is officially ready, you need to create the first, admin
+    account.
+  </p>
+  <p>
+    WARNING: Once you create the account, you will need it to create/promote new
+    admins.
+  </p>
+  <div id="content" class="section container">
+    <form class="text-right" action="/initialize" method="POST">
+      <div class="input-group mb-3">
+        <div class="input-group mb-3">
+          <input id="email" type="text" class="form-control" name="email"
+                 placeholder="Email" required>
+        </div>
+        <div class="input-group">
+          <input id="password" type="password" class="form-control" name="password"
+                 placeholder="Password" required>
+        </div>
+      </div>
+      <div class="input-group mb-3">
+        <input id="fname" type="text" class="form-control" name="fname"
+               placeholder="First Name">
+        <input id="lname" type="text" class="form-control" name="lname"
+               placeholder="Last Name">
+      </div>
+      <div class="btn-group btn-group-justified">
+        <input type="submit" class="btn btn-success" value="Finish" />
+      </div>
+      <!-- Creates a CSRF token, required by Play for non-GET requests.  Token is
+           embedded in a hidden input tag. -->
+      @helper.CSRF.formField
+    </form>
+  </div>
+</body>
+</html>

+ 62 - 15
server/app/views/viewLoader.scala.html

@@ -7,7 +7,66 @@
 	    href="@routes.Assets.versioned("images/favicon.png")">
   @webJarsUtil.locate("jquery.min.js").script()
   @webJarsUtil.locate("bootstrap.min.css").css()
+  @webJarsUtil.locate("jquery-ui.min.css").css()
   @webJarsUtil.locate("bootstrap.min.js").script()
+  @webJarsUtil.locate("Sortable.min.js").script()
+  <script src="https://SortableJS.github.io/Sortable/Sortable.js"></script>
+  <style type="text/css">
+    .scala-typeahead,
+    .st-query,
+    .st-hint {
+        line-height: 1.25em;
+        border: 1px solid #cccccc;
+        border-radius: 8px;
+        outline: none;
+    }
+    
+    .scala-typeahead {
+        background-color: #ffffff;
+    }
+    
+    .scala-typeahead:focus {
+        border: 2px solid #0097cf;
+    }
+    
+    .st-query {
+        box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+    }
+    
+    .st-hint {
+        color: #999999;
+    }
+    
+    .st-menu {
+        width: 100%;
+        padding: 8px 0;
+        background-color: #ffffff;
+        border: 1px solid rgba(0, 0, 0, 0.2);
+        border-radius: 8px;
+        box-shadow: 0 5px 10px rgba(0,0,0,.2);
+    }
+    
+    .st-suggestion {
+        padding: 3px 20px;
+        line-height: 24px;
+    }
+    
+    .st-suggestion:hover {
+        cursor: pointer;
+        color: #ffffff;
+        background-color: #0097cf;
+    }
+    
+    .st-suggestion.st-cursor {
+        color: #ffffff;
+        background-color: #0097cf;
+        
+    }
+
+    .ui-icon-pencil, .ui-icon-close {
+        cursor: pointer;
+    }
+  </style>
 </head>
 <body>
   <header class="page-header">
@@ -18,19 +77,8 @@
               aria-expanded="false" aria-label="Toggle navigation">
         <span class="navbar-toggler-icon"></span>
       </button>
-      <div class="collapse navbar-collapse" id="navbarNav">
-        <a href="#" class="navbar-brand"></a>
-        <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
-          <li class="nav-item active">
-            <a class="nav-link" href="#">Home</a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="#">Basic Foods</a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="#">Recipes</a>
-          </li>
-        </ul>
+      <div class="collapse navbar-collapse">
+        <div id="navbarNav" class="mr-auto mt-2 mt-lg-0"></div>
         <ul id="login-btns" class="navbar-nav">
           <li class="nav-item">
             <a class="nav-link" href="#" id="btn-login">Login</a>
@@ -47,8 +95,7 @@
       </div>
     </nav>
   </header>
-  <div id="content" class="section container">
-  </div>
+  <div id="content" class="section container"></div>
   <!-- Creates a CSRF token, required by Play for non-GET requests.  Token is
        embedded in a hidden input tag. -->
   @helper.CSRF.formField

+ 7 - 0
server/conf/application.conf

@@ -18,6 +18,13 @@ play.i18n.langs=["en"]
 db.default.driver=org.h2.Driver
 db.default.url="jdbc:h2:mem:users"
 
+oauth.dev = true
+
+init.admin.email = "test@test.org"
+init.admin.password = "12345678"
+init.admin.fname = "admin"
+init.admin.lname = "istrator"
+
 mongo.user="application"
 mongo.password="test"
 mongo.ssl=false

+ 34 - 12
server/conf/routes

@@ -7,45 +7,67 @@ GET   /               @controllers.Default.redirect(to = "/view")
 
 GET   /view           com.weEat.controllers.ViewController.loader()
 
+GET   /initialize     com.weEat.controllers.ViewController.initalizer()
+POST  /initialize     com.weEat.controllers.ViewController.initalize()
+
 # Shared Route
 # body: com.weEat.shared.models.UserRegistration
 # type: com.weEat.shared.models.UserAuthorization
-PUT   /user/          com.weEat.controllers.UserController.registerUser()
+PUT   /v1/user/          com.weEat.controllers.UserController.registerUser()
 
 # Shared Route
 # type: Seq[String]
-GET   /user/           com.weEat.controllers.UserController.getUsers()
+GET   /v1/user/           com.weEat.controllers.UserController.getUsers()
+
+# Shared Route
+# type: com.weEat.shared.models.User
+GET   /v1/user/:id        com.weEat.controllers.UserController.get(id: String)
 
 # Shared Route
 # body: com.weEat.shared.models.GrantRequest
 # type: com.weEat.shared.models.UserAuthorization
-POST  /authorize/     com.weEat.controllers.UserController.accessToken()
+POST  /v1/authorize/     com.weEat.controllers.UserController.accessToken()
 
 # Shared Route
 # body: com.weEat.shared.models.RefreshRequest
-DELETE /authorize/   com.weEat.controllers.UserController.revokeAccessToken()
+DELETE /v1/authorize/   com.weEat.controllers.UserController.revokeAccessToken()
 
 # Shared Route
 # mime: text/plain
 # type: String
-GET   /user/self/name/ com.weEat.controllers.UserController.getName()
+GET   /v1/user/self/name/ com.weEat.controllers.UserController.getName()
 
 # Shared Route
 # body: com.weEat.shared.models.FoodNode
-# type: com.weEat.shared.models.FoodNode
-PUT  /food/     com.weEat.controllers.FoodController.add()
+# type: com.weEat.shared.models.FoodNodeId
+PUT  /v1/food/     com.weEat.controllers.FoodController.add(uid: Option[String] ?= None)
 
 # Shared Route
-# type: Seq[com.weEat.shared.models.FoodNode]
-GET  /food/     com.weEat.controllers.FoodController.query()
+# type: Seq[com.weEat.shared.models.FoodNodeId]
+GET  /v1/food/     com.weEat.controllers.FoodController.query(q: String)
 
 # Shared Route
-# type: com.weEat.shared.models.FoodNode
-GET  /food/:id  com.weEat.controllers.FoodController.get(id: String)
+# type: Seq[com.weEat.shared.models.FoodNodeId]
+GET  /v1/food/     com.weEat.controllers.FoodController.all()
 
 # Shared Route
+# type: com.weEat.shared.models.FoodNodeId
+GET  /v1/food/:id  com.weEat.controllers.FoodController.get(id: String)
+
+# Share Route
+# type: com.weEat.shared.models.FoodNodeId
+#GET  /v1/food/:id/image/:img  com.weEat.controllers.FoodController.get(id: String, img: String)
+
+# Share Route
+# type: com.weEat.shared.models.FoodNodeId
+#PUT  /v1/food/:id/image  com.weEat.controllers.FoodController.addImageTo(id: String)
+
+# Share Route
+#DELETE  /v1/food/:id/image/:img  com.weEat.controllers.FoodController.deleteImage(id: String, img: String)
+
+# Share Route
 # type: gov.usda.nal.fdc.models.FoodItem
-GET   /fdc/food   com.weEat.controllers.USDAController.getFoods(id: String, fmt: String ?= "Full")
+#GET   /fdc/food   com.weEat.controllers.USDAController.getFoods(id: String, fmt: String ?= "Full")
 
 # Shared Route
 # type: gov.usda.nal.fdc.models.SearchResult

+ 281 - 77
shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala

@@ -4,128 +4,312 @@ import UnitType._
 import scala.util.{Try,Success,Failure}
 import scala.concurrent.Future
 import com.weEat.shared.exceptions._
-import gov.usda.nal.fdc.models.{SearchResultFood,FullFoodItem,FoodPortion}
-import gov.usda.nal.fdc.controllers.FoodController
-import gov.usda.nal.fdc.models.DataType._
 import play.api.libs.json.Json
 import com.weEat.shared.models.IdentifierHelper._
+import com.weEat.controllers.{FoodController,USDAController,UserController}
+import play.api.libs.json.{JsonConfiguration,JsonNaming}
+import gov.usda.nal.fdc.models.{FoodItem,SearchResultFood,FullFoodItem,FoodPortion}
+import gov.usda.nal.fdc.models.DataType._
 
 sealed trait FoodNode {
-  import FoodNode.NodeType._
+  import FoodNodeId.NodeType._
 
-  def _id: Option[Identifier]
-  def nodeType: NodeType
-  def defaultUnit: UnitType = MASS
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  def name: String
+  def defaultUnitType: UnitType = MASS
   def density: Option[Float] = None
   def massPerUnit: Option[Float] = None
-  def withId(id: Identifier): FoodNode
-}
-
-case class RecipeNode(
-  val _id: Option[Identifier],
-  val name: String,
-  val ingredients: Seq[RecipeNode.Ingredient],
-  val steps: Seq[String]
-) extends FoodNode {
-  val nodeType = FoodNode.NodeType.RECIPE
+  def withId(id: Identifier, uid: Identifier): FoodNodeId
+  def nutrient(str: String): Future[Float]
+  def nodeType: NodeType
+  //def imageIds: Seq[Identifier]
 
-  def withId(id: Identifier) = RecipeNode(Some(id), name, ingredients, steps)
+  //def image(idx: Int): APIRoute[Image] = FoodController.getImages(imageIds(idx))
 }
 
-object RecipeNode {
+sealed trait FoodNodeId extends FoodNode {
+  val _id: Identifier
+  val uid: Identifier
 
-  implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
+  def withId(id: Identifier): FoodNodeId = withId(id, uid)
 
-  case class Ingredient(val id: Identifier, val amount: Float) {
-    lazy val food: FoodNode = ???
-
-    private def convert(
-      amount: Double,
-      typ: UnitType,
-      to: MeasureUnit
-    ): Try[Double] = (typ, to.typ) match {
-      case (fTyp, tTyp) if fTyp == tTyp => Success(amount * to.conversionRatio)
-      case (MASS, VOLUME) => food.density match {
-        case Some(density) => convert(amount / density, VOLUME, to)
-        case None => Failure(new IncompleteDataException("density"))
-      }
-      case (MASS, NUMBER) => food.massPerUnit match {
-        case Some(massPerUnit) => convert(amount / massPerUnit, NUMBER, to)
-        case None => Failure(new IncompleteDataException("mass/unit"))
-      }
-      case (VOLUME, _) => food.density match {
-        case Some(density) => convert(amount * density, MASS, to)
-        case None => Failure(new IncompleteDataException("density"))
-      }
-      case (NUMBER, _) => food.density match {
-        case Some(massPerUnit) => convert(amount * massPerUnit, MASS, to)
-        case None => Failure(new IncompleteDataException("mass/unit"))
-      }
-    }
+  lazy val user = UserController.get(uid.toString)()
+}
 
-    def to(unit: MeasureUnit) = convert(amount, food.defaultUnit, unit)
+case class Ingredient(
+  val id: Ingredient.IngredientId,
+  val amount: Float,
+  val unit: MeasureUnit
+) {
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  lazy val food = id match {
+    case Ingredient.FoodNodeId(id) => FoodController.get(id.toString)()
+    case Ingredient.USDAId(id) =>
+      USDAController.getFood(id)().map(USDANodeNoId.fromFoodItem)
   }
 
-  object Ingredient {
-    implicit val ingredFmt = Json.using[Json.WithDefaultValues].format[Ingredient]
+  def in(target: MeasureUnit) = food.transform {
+    case Success(f) => unit.convert(f, amount, target)
+    case err: Failure[_] => err
   }
+}
 
+object Ingredient {
   import play.api.libs.json.{JsonConfiguration,JsonNaming}
+  import com.weEat.shared.models.{FoodNodeId => FoodNodeWithId}
+
+  sealed trait IngredientId {
+    def typ: String
+  }
+  case class FoodNodeId(id: Identifier) extends IngredientId {
+    def typ = "food"
+  }
+  case class USDAId(id: Long) extends IngredientId {
+    def typ = "usda"
+  }
+
+  implicit def foodNodeToId(food: FoodNodeWithId) = FoodNodeId(food._id)
+  implicit def foodItemToId(food: FoodItem) = USDAId(food.fdcId)
 
   implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
-  implicit val fmt = Json.using[Json.WithDefaultValues].format[RecipeNode]
+  implicit val identifierFmt = com.weEat.shared.models.IdentifierHelper.fmt
+  implicit val fnIdFmt = Json.using[Json.WithDefaultValues].format[FoodNodeId]
+  implicit val usdaIdFmt = Json.using[Json.WithDefaultValues].format[USDAId]
+  implicit val idFmt = Json.using[Json.WithDefaultValues].format[IngredientId]
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[Ingredient]
+
+  def fromFoodNode(
+    node: FoodNode,
+    amount: Float,
+    unit: MeasureUnit
+  ): Ingredient = {
+    class CachedIngredient(node: FoodNode, amount: Float, unit: MeasureUnit)
+        extends Ingredient(node match {
+          case food: FoodNodeWithId => FoodNodeId(food._id)
+          case food: USDANodeNoId => USDAId(food.fdcId)
+          case _ => ???
+        }, amount, unit) {
+
+      override lazy val food = Future.successful(node)
+
+      override def copy(
+        id: Ingredient.IngredientId = id,
+        amount: Float = amount,
+        unit: MeasureUnit = unit
+      ) = {
+        if (id != this.id) super.copy(id, amount)
+        else new CachedIngredient(node, amount, unit)
+      }
+    }
+    new CachedIngredient(node, amount, unit)
+  }
+}
+
+sealed trait RecipeNode extends FoodNode {
+  val nodeType = FoodNodeId.NodeType.RECIPE
+
+  def stdQties: Float
+  def stdQtiesPServing: Float
+  def defaultUnitType: UnitType
+  def ingredients: Seq[Ingredient]
+  def steps: Seq[String]
+  
+  def withId(id: Identifier, uid: Identifier) = RecipeNodeId(
+    id,
+    uid,
+    name,
+    stdQties,
+    stdQtiesPServing,
+    defaultUnitType,
+    ingredients,
+    steps,
+    density,
+    massPerUnit
+  )
+
+  def nutrient(num: String) = ingredients.map({ ingr =>
+    ingr.food.map( f => 
+      f.nutrient(num)
+        .map(_ * (ingr.unit
+          .convert(f, ingr.amount, f.defaultUnitType.defaultUnit) match {
+            case Success(amt) => amt.toFloat
+              // 2021-05-31: If we reach this case, it means we somehow can't
+              // convert the unit from whatever it's measured in to how we track
+              // it in the data layer.  The UI should prevent this from ever
+              // happening.
+            case Failure(e) => throw e
+          }))
+    ).flatten
+  }).fold(Future.successful(0.0f))({ case (a, b) =>
+    a.zipWith(b) { case (x, y) => x + y }
+  }).map { _ / stdQties / 100.0f }
 }
 
-case class USDANode(
-  val _id: Option[Identifier],
+case class RecipeNodeNoId(
   val name: String,
-  val fdcId: Long,
+  val stdQties: Float,
+  val stdQtiesPServing: Float,
+  override val defaultUnitType: UnitType,
+  val ingredients: Seq[Ingredient],
+  val steps: Seq[String],
   override val density: Option[Float],
-  override val massPerUnit: Option[Float],
-  override val defaultUnit: UnitType,
-  val calories: Float,
-  val nutrients: Map[String, Float]
-) extends FoodNode {
-  val nodeType = FoodNode.NodeType.USDA
+  override val massPerUnit: Option[Float]
+) extends RecipeNode
+
+case class RecipeNodeId(
+  val _id: Identifier,
+  val uid: Identifier,
+  val name: String,
+  val stdQties: Float,
+  val stdQtiesPServing: Float,
+  override val defaultUnitType: UnitType,
+  val ingredients: Seq[Ingredient],
+  val steps: Seq[String],
+  override val density: Option[Float],
+  override val massPerUnit: Option[Float]
+) extends RecipeNode with FoodNodeId
+
+object RecipeNodeNoId {
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[RecipeNodeNoId]
+}
+
+object RecipeNodeId {
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[RecipeNodeId]
+}
 
-  def withId(id: Identifier) = USDANode(
-    Some(id),
+sealed trait USDANode extends FoodNode {
+  val nodeType = FoodNodeId.NodeType.USDA
+
+  def fdcId: Long
+  def calories: Float
+  def nutrients: Map[String, Float]
+
+  def withId(id: Identifier, uid: Identifier) = USDANodeId(
+    id,
+    uid,
     name,
     fdcId,
     density,
     massPerUnit,
-    defaultUnit,
     calories,
     nutrients
   )
+
+  def nutrient(str: String) = Future.successful(nutrients.get(str).getOrElse(0.0f))
 }
 
-object USDANode {
+case class USDANodeNoId(
+  val name: String,
+  val fdcId: Long,
+  override val density: Option[Float],
+  override val massPerUnit: Option[Float],
+  val calories: Float,
+  val nutrients: Map[String, Float]
+) extends USDANode
+
+case class USDANodeId(
+  val _id: Identifier,
+  val uid: Identifier,
+  val name: String,
+  val fdcId: Long,
+  override val density: Option[Float],
+  override val massPerUnit: Option[Float],
+  val calories: Float,
+  val nutrients: Map[String, Float]
+) extends USDANode with FoodNodeId
+
+object USDANodeNoId {
   val kcalNutrientId = 1008
 
-  def fromSearchResult(usda: SearchResultFood) = USDANode(
-    None,
+  def fromSearchResult(usda: SearchResultFood) = USDANodeNoId(
     usda.description,
     usda.fdcId,
     None,
     None,
-    MASS,
-    usda.foodNutrients.find(_.nutrientId == kcalNutrientId).map(_.value)
-          .getOrElse(0.0f),
-    usda.foodNutrients.map(x => (x.nutrientNumber, x.value)).toMap
+    usda.foodNutrients
+      .find(_.nutrientId == kcalNutrientId)
+      .map(_.value)
+      .getOrElse(0.0f),
+    usda.foodNutrients
+      .map(x => (x.nutrientNumber, x.value)).toMap
   )
 
+  import gov.usda.nal.fdc.models._
+
+  def fromFoodItem(usda: FoodItem) = usda match {
+    case AbridgedFoodItem(id, _, desc, nutr, _, _, _, _, _) => USDANodeNoId(
+      desc,
+      id,
+      None,
+      None,
+      nutr.find(_.nutrientId == kcalNutrientId).map(_.value).getOrElse(0.0f),
+      nutr.map(x => (x.nutrientNumber, x.value)).toMap
+    )
+    case BrandedFoodItem(id, _, desc, _, _, _, _, _, _, _, _, servSize, servUnit,
+      _, nutr, _, _, _) => USDANodeNoId(
+      desc,
+      id,
+      None,
+      None,
+      nutr.find(_.nutrient.id == kcalNutrientId).flatMap(_.amount).getOrElse(0.0f),
+      nutr.flatMap(x => x.amount.map(amt => (x.nutrient.number, amt))).toMap
+    )
+    case FoundationFoodItem(id, _, desc, _, _, _, _, _, _, _, _, nutr, port, _,
+      _) => USDANodeNoId(
+      desc,
+      id,
+      None,
+      None,
+      nutr.calories.getOrElse(0.0f),
+      Seq(
+        nutr.fat.map(amt => ("204", amt)),
+        nutr.saturatedFat.map(amt => ("606", amt)),
+        nutr.transFat.map(amt => ("605", amt)),
+        nutr.cholesterol.map(amt => ("601", amt)),
+        nutr.sodium.map(amt => ("307", amt)),
+        nutr.carbohydrates.map(amt => ("205", amt)),
+        nutr.fiber.map(amt => ("291", amt)),
+        nutr.sugars.map(amt => ("269", amt)),
+        nutr.protein.map(amt => ("203", amt)),
+        nutr.calcium.map(amt => ("301", amt)),
+        nutr.iron.map(amt => ("303", amt)),
+        nutr.postassium.map(amt => ("306", amt)),
+        nutr.calories.map(amt => ("208", amt))
+      ).flatten.toMap
+    )
+    case SurveyFoodItem(id, _, desc, _, _, _, _, _, _, port, _, _) => ???
+    case SRLegacyFoodItem(_, _, desc, _, _, _, _, _, _, nutr, _, _, _, port, _) => ???
+    case SampleFoodItem(id, _, desc, _, _, _) => ???
+  }
+
   import play.api.libs.functional.syntax._
-  import play.api.libs.json.{Format,JsonConfiguration,JsonNaming}
 
   implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
   implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
-  implicit val fmt = Json.using[Json.WithDefaultValues].format[USDANode]
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[USDANodeNoId]
 }
 
-object FoodNode {
+object USDANodeId {
+  import play.api.libs.functional.syntax._
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[USDANodeId]
+}
+
+object FoodNodeId {
   val collectionName = "foods"
 
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json._
+  import java.text.ParseException
+
   object NodeType extends Enumeration {
     type NodeType = Value
     val USDA, RECIPE = Value
@@ -136,22 +320,42 @@ object FoodNode {
   }
 
   import NodeType._
+
+  implicit val requestWrites = Writes[FoodNodeId](requ =>
+    (requ match {
+      case node: USDANodeId => Json.toJson(node)
+      case node: RecipeNodeId => Json.toJson(node)
+    }).asInstanceOf[JsObject] + ("node_type", Json.toJson(requ.nodeType))
+  )
+
+  implicit val requestReads = Reads[FoodNodeId](json =>
+    (json \ "node_type").as[NodeType] match {
+      case USDA => Json.fromJson[USDANodeId](json)
+      case RECIPE => Json.fromJson[RecipeNodeId](json)
+    }
+  )
+}
+
+object FoodNode {
+  import FoodNodeId.NodeType
+  import FoodNodeId.NodeType._
   import play.api.libs.functional.syntax._
   import play.api.libs.json._
   import java.text.ParseException
 
   implicit val requestWrites = Writes[FoodNode](requ =>
     (requ match {
-      case node: USDANode => Json.toJson(node)
-      case node: RecipeNode => Json.toJson(node)
+      case node: USDANodeNoId => Json.toJson(node)
+      case node: RecipeNodeNoId => Json.toJson(node)
+      case node: FoodNodeId => Json.toJson(node)
     }).asInstanceOf[JsObject] + ("node_type", Json.toJson(requ.nodeType))
   )
 
   implicit val requestReads = Reads[FoodNode](json =>
     (json \ "node_type").as[NodeType] match {
-      case USDA => Json.fromJson[USDANode](json)
-      case RECIPE => Json.fromJson[RecipeNode](json)
-      case _ => ???
+      case USDA => Json.fromJson[USDANodeNoId](json)
+      case RECIPE => Json.fromJson[RecipeNodeNoId](json)
     }
   )
 }
+

+ 4 - 2
shared/shared/src/main/scala/com/weEat/shared/models/GrantRequest.scala

@@ -30,11 +30,11 @@ case class RefreshRequest (
 
 /* Access token response */
 case class UserAuthorization (
-  //val scope: String,
   val accessToken: String,
   val tokenType: String,
   val expiresIn: scala.concurrent.duration.FiniteDuration,
-  val refreshToken: String
+  val refreshToken: String,
+  val scope: Set[String] = Set.empty
 )
 
 object GrantError {
@@ -86,5 +86,7 @@ object UserAuthorization {
   implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
   implicit val durationFormat = Format.of[Long]
     .inmap[FiniteDuration](_.seconds, _.toSeconds)
+  implicit val setFormat = Format.of[String]
+    .inmap[Set[String]](_.split(raw"\s+").toSet, _.mkString(" "))
   implicit val authorizationFormat = Json.format[UserAuthorization]
 }

+ 77 - 0
shared/shared/src/main/scala/com/weEat/shared/models/MeasureUnit.scala

@@ -1,5 +1,8 @@
 package com.weEat.shared.models
 
+import scala.util.{Try,Success,Failure}
+import com.weEat.shared.exceptions._
+
 sealed trait MeasureUnit {
   def typ: UnitType.UnitType
   /* defaultUnit/thisUnit = conversionRatio */
@@ -10,6 +13,38 @@ sealed trait MeasureUnit {
 
   def names = (name +: abr +: altNames).filterNot(_.length == 0)
   def lNames = names.map(_.toLowerCase)
+
+  import UnitType._
+
+  override def toString = abr
+
+  def convert(
+    food: FoodNode,
+    amount: Double,
+    to: MeasureUnit
+  ): Try[Double] = ((typ, to.typ) match {
+    case (fTyp, tTyp) if fTyp == tTyp => Success(amount * to.conversionRatio)
+    case (MASS, VOLUME) => food.density match {
+      case Some(density) => VOLUME.defaultUnit
+          .convert(food, amount / density, to)
+      case None => Failure(new IncompleteDataException("density"))
+    }
+    case (MASS, NUMBER) => food.massPerUnit match {
+      case Some(massPerUnit) => NUMBER.defaultUnit
+          .convert(food, amount / massPerUnit, to)
+      case None => Failure(new IncompleteDataException("mass/unit"))
+    }
+    case (VOLUME, _) => food.density match {
+      case Some(density) => MASS.defaultUnit
+          .convert(food, amount * density, to)
+      case None => Failure(new IncompleteDataException("density"))
+    }
+    case (NUMBER, _) => food.massPerUnit match {
+      case Some(massPerUnit) => MASS.defaultUnit.
+          convert(food, amount * massPerUnit, to)
+      case None => Failure(new IncompleteDataException("mass/unit"))
+    }
+  }).map(_ * conversionRatio)
 }
 
 object MeasureUnit {
@@ -33,6 +68,10 @@ object MeasureUnit {
     Dozen
   )
 
+  def fromString(str: String) = units.find(_.abr == str)
+
+  def apply(i: Int) = units(i)
+
   private def matchDegree(haystack: String)(needle: String) = {
     val startsWithNeedle = s"$needle[^\\w]".r
     val containsNeedle = s"[^\\w]$needle[^\\w]".r
@@ -51,6 +90,19 @@ object MeasureUnit {
   def closestPair[T](seq: Seq[(T, MeasureUnit, String)]) = seq.maxByOption(p =>
     p._2.lNames.flatMap(matchDegree(p._3.toLowerCase)(_)).maxOption
   )
+
+  import play.api.libs.json._
+  implicit val writes = Writes[MeasureUnit](unit => new JsString(unit.toString))
+
+  implicit val reads = Reads[MeasureUnit]({
+    case json: JsString => fromString(json.value) match {
+      case Some(unit) => new JsSuccess(unit)
+      case None => JsError(
+        s"""No unit found with the abbreviation, "${json.value}"."""
+      )
+    }
+    case _ => ???
+  })
 }
 
 // object UnitType extends Enumeration {
@@ -84,6 +136,17 @@ object UnitType {
     case NUMBER => Count
   }
 
+  def standardQuanity(typ: UnitType) = typ match {
+    case MASS => 100.0f
+    case VOLUME => 100.0f
+    case NUMBER => 1.0f
+  }
+
+  implicit class UnitTypeHelper(typ: UnitType) {
+    def defaultUnit: MeasureUnit = UnitType.defaultUnit(typ)
+    def standardQuanity: Float = UnitType.standardQuanity(typ)
+  }
+
   import play.api.libs.json.{Json,Reads,Writes}
   import julienrf.json.derived
 
@@ -154,6 +217,20 @@ object Liter extends MeasureUnit {
   override val abr = "L"
 }
 
+object CmCube extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 1.0
+  override val name = "Cubic centimeter"
+  override val abr = "cm^3"
+}
+
+object InCube extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 16.38706
+  override val name = "Cubic inch"
+  override val abr = "in^3"
+}
+
 object FluidOunceUS extends MeasureUnit {
   override val typ = VOLUME
   override val conversionRatio = 29.57353

+ 3 - 1
shared/shared/src/main/scala/com/weEat/shared/models/User.scala

@@ -1,9 +1,11 @@
 package com.weEat.shared.models
 
 import play.api.libs.json.{Format,Json}
+import com.weEat.shared.models.IdentifierHelper._
 
 /* Basic User information */
-case class User (
+case class User(
+  val _id: Identifier,
   val fname: String,
   val lname: String,
   val email: String