From 00dbce6e94308e13d33e32bf1619dbddb9947615 Mon Sep 17 00:00:00 2001
From: Antonio171003 <luis.antonio.171003@gmail.com>
Date: Tue, 3 Sep 2024 21:01:05 -0700
Subject: [PATCH 1/3] Added a Data Provider to manage images

---
 .../src/main/resources/application.conf       | 10 +++++
 .../wiringbits/spra/ui/web/AdminView.scala    |  5 +--
 .../spra/ui/web/facades/DataProvider.scala    | 13 ++++++
 .../spra/ui/web/facades/package.scala         | 30 ++++++++++++--
 .../wiringbits/spra/ui/web/utils/Images.scala | 40 +++++++++++++++++++
 5 files changed, 91 insertions(+), 7 deletions(-)
 create mode 100644 spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala

diff --git a/spra-play-server/src/main/resources/application.conf b/spra-play-server/src/main/resources/application.conf
index 1fe710f..1a7b3da 100644
--- a/spra-play-server/src/main/resources/application.conf
+++ b/spra-play-server/src/main/resources/application.conf
@@ -30,6 +30,16 @@ dataExplorer {
       }
       referenceDisplayField = "email"
     }
+
+    images {
+      tableName = "images"
+      primaryKeyField = "image_id"
+      nonEditableColumns = ["image_id", "created_at"]
+      canBeDeleted = false
+      createFilter {
+        requiredColumns = ["name", "data"]
+      }
+    }
   }
 }
 
diff --git a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/AdminView.scala b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/AdminView.scala
index d80196b..53cd6a6 100644
--- a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/AdminView.scala
+++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/AdminView.scala
@@ -3,13 +3,12 @@ package net.wiringbits.spra.ui.web
 import net.wiringbits.spra.api.models.AdminGetTables
 import net.wiringbits.spra.ui.web.components.{CreateGuesser, EditGuesser, ListGuesser}
 import net.wiringbits.spra.ui.web.facades.reactadmin.{Admin, Resource}
-import net.wiringbits.spra.ui.web.facades.simpleRestProvider
+import net.wiringbits.spra.ui.web.facades.createDataProvider
 import net.wiringbits.spra.ui.web.models.DataExplorerSettings
 import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global
 import slinky.core.facade.{Hooks, ReactElement}
 import slinky.core.{FunctionalComponent, KeyAddingStage}
 import slinky.web.html.{div, h1}
-
 import scala.util.{Failure, Success}
 
 object AdminView {
@@ -52,7 +51,7 @@ object AdminView {
     }
 
     div()(
-      Admin(simpleRestProvider(tablesUrl))(buildResources),
+      Admin(createDataProvider(tablesUrl))(buildResources),
       error.map(h1(_))
     )
   }
diff --git a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/DataProvider.scala b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/DataProvider.scala
index 020648c..08a6ae6 100644
--- a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/DataProvider.scala
+++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/DataProvider.scala
@@ -1,6 +1,19 @@
 package net.wiringbits.spra.ui.web.facades
 
 import scala.scalajs.js
+import scala.scalajs.js.annotation.JSImport
 
 @js.native
 trait DataProvider extends js.Object
+
+@js.native
+@JSImport("ra-data-simple-rest", JSImport.Default)
+// https://www.npmjs.com/package/ra-data-simple-rest
+def simpleRestProvider(url: String): DataProvider = js.native
+
+@js.native
+@JSImport("react-admin", "withLifecycleCallbacks")
+// https://marmelab.com/react-admin/withLifecycleCallbacks.html
+object WithLifecycleCallbacks extends js.Object {
+  def apply(dataProvider: DataProvider, callbacks: js.Array[js.Object]): DataProvider = js.native
+}
diff --git a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/package.scala b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/package.scala
index f6c484e..95267c8 100644
--- a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/package.scala
+++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/facades/package.scala
@@ -1,10 +1,32 @@
 package net.wiringbits.spra.ui.web
 
 import scala.scalajs.js
-import scala.scalajs.js.annotation.JSImport
+import net.wiringbits.spra.ui.web.utils.Images.*
+import org.scalajs.dom.File
 
 package object facades {
-  @js.native
-  @JSImport("ra-data-simple-rest", JSImport.Default)
-  def simpleRestProvider(url: String): DataProvider = js.native
+  def createDataProvider(url: String): DataProvider = {
+    val baseDataProvider = simpleRestProvider(url)
+    WithLifecycleCallbacks(
+      baseDataProvider,
+      js.Array(
+        js.Dynamic.literal(
+          resource = "images",
+          afterRead = (record: js.Dynamic, dataProvider: js.Any) => {
+            val hexImage = record.data.asInstanceOf[String]
+            val urlImage = convertHexToImage(hexImage)
+            record.updateDynamic("data")(urlImage)
+            record
+          },
+          beforeSave = (data: js.Dynamic, dataProvider: js.Any) => {
+            val rawFile = data.data.rawFile.asInstanceOf[File]
+            convertImageToByteArray(rawFile).`then` { value =>
+              data.updateDynamic("data")(value.asInstanceOf[js.Any])
+              data
+            }
+          }
+        )
+      )
+    )
+  }
 }
diff --git a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
new file mode 100644
index 0000000..4975635
--- /dev/null
+++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
@@ -0,0 +1,40 @@
+package net.wiringbits.spra.ui.web.utils
+
+import org.scalajs.dom
+import org.scalajs.dom.{Blob, File}
+import scala.util.{Failure, Success, Try}
+import scala.scalajs.js.Promise
+import scala.scalajs.js.typedarray.{ArrayBuffer, Int8Array, Uint8Array}
+import scala.scalajs.js
+
+object Images {
+  def convertImageToByteArray(image: dom.File): js.Promise[String] = {
+    new js.Promise[String]((resolve, reject) => {
+      val reader = new dom.FileReader()
+      reader.onload = { (e: dom.Event) =>
+        val arrayBuffer = reader.result.asInstanceOf[ArrayBuffer]
+        val byteArray = new Int8Array(arrayBuffer).toArray
+        resolve(byteArray.mkString("[", ", ", "]"))
+      }
+      reader.onerror = { (e: dom.Event) =>
+        reject(new js.Error("Failed to read file"))
+      }
+      reader.readAsArrayBuffer(image)
+    })
+  }
+
+  def convertHexToImage(imageHex: String): String = {
+    // Remove the "0x" prefix from the hex string, as it's not part of the actual image data
+    val hex = imageHex.tail.tail
+    val imageBinary: Array[Byte] =
+      if ((hex.length % 2) == 1)
+        Array.empty
+      else
+        Try(hex.grouped(2).map { hex => Integer.parseInt(hex, 16).toByte }.toArray) match {
+          case Success(value) => value
+          case Failure(_) => Array.empty
+        }
+    val byteArray = Uint8Array(js.Array(imageBinary.map(_.toShort): _*))
+    dom.URL.createObjectURL(dom.Blob(js.Array(byteArray.buffer)))
+  }
+}

From df59cdae3297e60373f877b3b94f916a7b8acdfd Mon Sep 17 00:00:00 2001
From: Antonio171003 <luis.antonio.171003@gmail.com>
Date: Thu, 5 Sep 2024 20:34:33 -0700
Subject: [PATCH 2/3] Add exceptions to the convertHexToImage function to check
 that the argument is as expected (Hexadecimal String).

---
 .../wiringbits/spra/ui/web/utils/Images.scala   | 17 +++++++++--------
 1 file changed, 9 insertions(+), 8 deletions(-)

diff --git a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
index 4975635..b17203b 100644
--- a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
+++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
@@ -24,16 +24,17 @@ object Images {
   }
 
   def convertHexToImage(imageHex: String): String = {
-    // Remove the "0x" prefix from the hex string, as it's not part of the actual image data
+    // Check if the argument is a hexadecimal string"
+    if (!imageHex.startsWith("\\x") || (imageHex.length % 2) == 1) {
+      throw new IllegalArgumentException(s"Error: Expected a hexadecimal string but found: $imageHex")
+    }
+    // Remove the "\x" prefix from the hex string, as it's not part of the actual image data
     val hex = imageHex.tail.tail
     val imageBinary: Array[Byte] =
-      if ((hex.length % 2) == 1)
-        Array.empty
-      else
-        Try(hex.grouped(2).map { hex => Integer.parseInt(hex, 16).toByte }.toArray) match {
-          case Success(value) => value
-          case Failure(_) => Array.empty
-        }
+      Try(hex.grouped(2).map { hex => Integer.parseInt(hex, 16).toByte }.toArray) match {
+        case Success(value) => value
+        case Failure(_) => Array.empty
+      }
     val byteArray = Uint8Array(js.Array(imageBinary.map(_.toShort): _*))
     dom.URL.createObjectURL(dom.Blob(js.Array(byteArray.buffer)))
   }

From ca0ce83046001ccd2df113fd232eec7233208b02 Mon Sep 17 00:00:00 2001
From: Antonio171003 <luis.antonio.171003@gmail.com>
Date: Mon, 9 Sep 2024 21:21:01 -0700
Subject: [PATCH 3/3] Added an object parseHexString to convert hex strings to
 a byte array and also added tests for it.

---
 .../wiringbits/spra/ui/web/utils/Images.scala | 13 +---
 .../spra/ui/web/utils/ParseHexString.scala    | 19 ++++++
 .../utils/ParseHexStringSpec.scala            | 65 +++++++++++++++++++
 3 files changed, 85 insertions(+), 12 deletions(-)
 create mode 100644 spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/ParseHexString.scala
 create mode 100644 spra-web/src/test/scala/net.wiringbits.spra.admin/utils/ParseHexStringSpec.scala

diff --git a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
index b17203b..3eec237 100644
--- a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
+++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala
@@ -2,7 +2,6 @@ package net.wiringbits.spra.ui.web.utils
 
 import org.scalajs.dom
 import org.scalajs.dom.{Blob, File}
-import scala.util.{Failure, Success, Try}
 import scala.scalajs.js.Promise
 import scala.scalajs.js.typedarray.{ArrayBuffer, Int8Array, Uint8Array}
 import scala.scalajs.js
@@ -24,17 +23,7 @@ object Images {
   }
 
   def convertHexToImage(imageHex: String): String = {
-    // Check if the argument is a hexadecimal string"
-    if (!imageHex.startsWith("\\x") || (imageHex.length % 2) == 1) {
-      throw new IllegalArgumentException(s"Error: Expected a hexadecimal string but found: $imageHex")
-    }
-    // Remove the "\x" prefix from the hex string, as it's not part of the actual image data
-    val hex = imageHex.tail.tail
-    val imageBinary: Array[Byte] =
-      Try(hex.grouped(2).map { hex => Integer.parseInt(hex, 16).toByte }.toArray) match {
-        case Success(value) => value
-        case Failure(_) => Array.empty
-      }
+    val imageBinary: Array[Byte] = ParseHexString.toByteArray(imageHex)
     val byteArray = Uint8Array(js.Array(imageBinary.map(_.toShort): _*))
     dom.URL.createObjectURL(dom.Blob(js.Array(byteArray.buffer)))
   }
diff --git a/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/ParseHexString.scala b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/ParseHexString.scala
new file mode 100644
index 0000000..74a8312
--- /dev/null
+++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/ParseHexString.scala
@@ -0,0 +1,19 @@
+package net.wiringbits.spra.ui.web.utils
+
+import scala.util.{Failure, Success, Try}
+
+object ParseHexString {
+  def toByteArray(hexString: String): Array[Byte] = {
+    // Check if the argument is a hexadecimal string"
+    if (!hexString.startsWith("\\x") || (hexString.length % 2) == 1) {
+      throw new IllegalArgumentException(s"Error: Expected a hexadecimal string but found: $hexString")
+    }
+    // Remove the "\x" prefix from the hex string, as it's not part of the actual data
+    val hex = hexString.tail.tail
+    Try(hex.grouped(2).map { hex => Integer.parseInt(hex, 16).toByte }.toArray) match {
+      case Success(value) => value
+      case Failure(_) =>
+        throw new IllegalArgumentException(s"Error: Expected a hexadecimal string but found: $hexString")
+    }
+  }
+}
diff --git a/spra-web/src/test/scala/net.wiringbits.spra.admin/utils/ParseHexStringSpec.scala b/spra-web/src/test/scala/net.wiringbits.spra.admin/utils/ParseHexStringSpec.scala
new file mode 100644
index 0000000..6ff7c2c
--- /dev/null
+++ b/spra-web/src/test/scala/net.wiringbits.spra.admin/utils/ParseHexStringSpec.scala
@@ -0,0 +1,65 @@
+package net.wiringbits.spra.admin
+
+import net.wiringbits.spra.ui.web.utils.ParseHexString
+import org.scalatest.matchers.must.Matchers.{be, must}
+import org.scalatest.wordspec.AnyWordSpec
+import scala.util.Try
+
+class ParseHexStringSpec extends AnyWordSpec {
+
+  "convert valids hex string to a byte array" should {
+    val hexData = List(
+      "\\x5F3A9C1B7D",
+      "\\x9E2D5B8F2A",
+      "\\xA3B7D2E6C4",
+      "\\x4F1E9A6D3B",
+      "\\x6C2A8F4B7E",
+      "\\xA3B9F56E8D4C721F9A6D3F2C",
+      "\\x5F2C8E7B9A1D4E6F3B7A4F2D",
+      "\\x7E9B6C2A5F8D4B3C6A2E1F9D",
+      "\\xD4A7C8F25B3E7A9F4C8D6E1B",
+      "\\xF3A59B4C7D2E8F1A6B9D3C4F2A7E1D5C9A3B6E8D4F2C1A7B3D9E4F1C6A2B"
+    )
+
+    val conversionBytea: List[Array[Byte]] = List(
+      Array(0x5f, 0x3a, 0x9c, 0x1b, 0x7d),
+      Array(0x9e, 0x2d, 0x5b, 0x8f, 0x2a),
+      Array(0xa3, 0xb7, 0xd2, 0xe6, 0xc4),
+      Array(0x4f, 0x1e, 0x9a, 0x6d, 0x3b),
+      Array(0x6c, 0x2a, 0x8f, 0x4b, 0x7e),
+      Array(0xa3, 0xb9, 0xf5, 0x6e, 0x8d, 0x4c, 0x72, 0x1f, 0x9a, 0x6d, 0x3f, 0x2c),
+      Array(0x5f, 0x2c, 0x8e, 0x7b, 0x9a, 0x1d, 0x4e, 0x6f, 0x3b, 0x7a, 0x4f, 0x2d),
+      Array(0x7e, 0x9b, 0x6c, 0x2a, 0x5f, 0x8d, 0x4b, 0x3c, 0x6a, 0x2e, 0x1f, 0x9d),
+      Array(0xd4, 0xa7, 0xc8, 0xf2, 0x5b, 0x3e, 0x7a, 0x9f, 0x4c, 0x8d, 0x6e, 0x1b),
+      Array(0xf3, 0xa5, 0x9b, 0x4c, 0x7d, 0x2e, 0x8f, 0x1a, 0x6b, 0x9d, 0x3c, 0x4f, 0x2a, 0x7e, 0x1d, 0x5c, 0x9a, 0x3b,
+        0x6e, 0x8d, 0x4f, 0x2c, 0x1a, 0x7b, 0x3d, 0x9e, 0x4f, 0x1c, 0x6a, 0x2b)
+    ).map(_.map(_.toByte))
+
+    hexData.zip(conversionBytea).foreach { case (hex, expectedBytes) =>
+      s"convert valid data $hex" in {
+        ParseHexString.toByteArray(hex) must be(expectedBytes)
+      }
+    }
+  }
+
+  "throw an exception for a string containing non-hexadecimal characters" should {
+    val hexData = List(
+      "\\a5F3A9C1B7D",
+      "9E2D5B8F2A",
+      "\\xG3B7D2E6C4",
+      "\\x4F1E9A76D3B",
+      "6C42A8F4G7E",
+      "\\xA3B9F56E8P4C721F9A6D3F2C",
+      "\\x5F2C8E7B9AA1D4E6F3B7A4F2D",
+      "\\x7E96C2A5F8D4B3C6A2E1F9D",
+      "\\xD4A7C8F25B3E7JLAKSNSLKAS",
+      "\\T1542ABF3A59B4C7D2E8F1A6B9D3C4F2A7E1D5C9A3B6E8D4F2C1A7B3D9E4F1C6A2B"
+    )
+
+    hexData.foreach { value =>
+      s"throw an exeption for: $value" in {
+        Try(ParseHexString.toByteArray(value)).isFailure must be(true)
+      }
+    }
+  }
+}