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..3eec237 --- /dev/null +++ b/spra-web/src/main/scala/net/wiringbits/spra/ui/web/utils/Images.scala @@ -0,0 +1,30 @@ +package net.wiringbits.spra.ui.web.utils + +import org.scalajs.dom +import org.scalajs.dom.{Blob, File} +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 = { + 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) + } + } + } +}