From ddd363917c369c1c3066a6472edbbed54c54d8cc Mon Sep 17 00:00:00 2001 From: Alinson Xavier Date: Wed, 10 Apr 2019 20:07:15 -0500 Subject: [PATCH] Implement JsFileStorage using IndexedDB --- .../kotlin/org/isoron/platform/io/Database.kt | 1 - .../kotlin/org/isoron/platform/io/Files.kt | 50 ++++++-- .../commonTest/kotlin/org/isoron/BaseTest.kt | 7 +- .../kotlin/org/isoron/DependencyResolver.kt | 2 +- .../org/isoron/platform/io/FilesTest.kt | 17 +++ .../org/isoron/platform/io/JsDatabase.kt | 1 - .../kotlin/org/isoron/platform/io/JsFiles.kt | 107 +++++++++++++++--- .../kotlin/org/isoron/DependencyResolver.kt | 11 +- .../jsTest/kotlin/org/isoron/JsAsyncTests.kt | 2 +- .../org/isoron/platform/io/JavaFiles.kt | 47 ++++---- .../kotlin/org/isoron/DependencyResolver.kt | 2 +- .../kotlin/org/isoron/JavaAsyncTests.kt | 2 +- .../kotlin/org/isoron/uhabits/BaseViewTest.kt | 33 +++--- 13 files changed, 214 insertions(+), 68 deletions(-) diff --git a/core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt b/core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt index 7b6c33a77..14faec0a7 100644 --- a/core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt +++ b/core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt @@ -94,7 +94,6 @@ suspend fun Database.migrateTo(newVersion: Int, fileOpener: FileOpener, log: Log for (v in (currentVersion + 1)..newVersion) { val sv = if(v < 10) "00$v" else if (v<100) "0$v" else "$v" val filename = "migrations/$sv.sql" - println(filename) val migrationFile = fileOpener.openResourceFile(filename) for (line in migrationFile.lines()) { if (line.isEmpty()) continue diff --git a/core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt b/core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt index 71a038cd5..e9fe8f92d 100644 --- a/core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt +++ b/core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt @@ -26,9 +26,11 @@ interface FileOpener { * * The path is relative to the assets folder. For example, to open * assets/main/migrations/09.sql you should provide migrations/09.sql - * as the filename. + * as the path. + * + * This function always succeed, even if the file does not exist. */ - fun openResourceFile(filename: String): ResourceFile + fun openResourceFile(path: String): ResourceFile /** * Opens a file which was not shipped with the application, such as @@ -36,26 +38,54 @@ interface FileOpener { * * The path is relative to the user folder. For example, if the application * stores the user data at /home/user/.loop/ and you wish to open the file - * /home/user/.loop/crash.log, you should provide crash.log as the filename. + * /home/user/.loop/crash.log, you should provide crash.log as the path. + * + * This function always succeed, even if the file does not exist. */ - fun openUserFile(filename: String): UserFile + fun openUserFile(path: String): UserFile } /** * Represents a file that was created after the application was installed, as a - * result of some user action, such as databases and logs. These files can be - * deleted. + * result of some user action, such as databases and logs. */ interface UserFile { - fun delete() - fun exists(): Boolean + /** + * Deletes the user file. If the file does not exist, nothing happens. + */ + suspend fun delete() + + /** + * Returns true if the file exists. + */ + suspend fun exists(): Boolean + + /** + * Returns the lines of the file. If the file does not exist, throws an + * exception. + */ + suspend fun lines(): List } /** * Represents a file that was shipped with the application, such as migration - * files or translations. These files cannot be deleted. + * files or database templates. */ interface ResourceFile { - fun copyTo(dest: UserFile) + /** + * Copies the resource file to the specified user file. If the user file + * already exists, it is replaced. If not, a new file is created. + */ + suspend fun copyTo(dest: UserFile) + + /** + * Returns the lines of the resource file. If the file does not exist, + * throws an exception. + */ suspend fun lines(): List + + /** + * Returns true if the file exists. + */ + suspend fun exists(): Boolean } \ No newline at end of file diff --git a/core/src/commonTest/kotlin/org/isoron/BaseTest.kt b/core/src/commonTest/kotlin/org/isoron/BaseTest.kt index 6ad312e82..d90dfbf90 100644 --- a/core/src/commonTest/kotlin/org/isoron/BaseTest.kt +++ b/core/src/commonTest/kotlin/org/isoron/BaseTest.kt @@ -19,7 +19,6 @@ package org.isoron -open class BaseTest { - val resolver = DependencyResolver() - val fileOpener = resolver.getFileOpener() -} \ No newline at end of file +val resolver = DependencyResolver() + +open class BaseTest \ No newline at end of file diff --git a/core/src/commonTest/kotlin/org/isoron/DependencyResolver.kt b/core/src/commonTest/kotlin/org/isoron/DependencyResolver.kt index 739bebacf..af81ce9f7 100644 --- a/core/src/commonTest/kotlin/org/isoron/DependencyResolver.kt +++ b/core/src/commonTest/kotlin/org/isoron/DependencyResolver.kt @@ -23,7 +23,7 @@ import org.isoron.platform.gui.* import org.isoron.platform.io.* expect class DependencyResolver() { - fun getFileOpener(): FileOpener + suspend fun getFileOpener(): FileOpener suspend fun getDatabase(): Database fun createCanvas(width: Int, height: Int): Canvas fun exportCanvas(canvas: Canvas, filename: String) diff --git a/core/src/commonTest/kotlin/org/isoron/platform/io/FilesTest.kt b/core/src/commonTest/kotlin/org/isoron/platform/io/FilesTest.kt index 6532f4b09..1bf7ab2bf 100644 --- a/core/src/commonTest/kotlin/org/isoron/platform/io/FilesTest.kt +++ b/core/src/commonTest/kotlin/org/isoron/platform/io/FilesTest.kt @@ -24,12 +24,29 @@ import kotlin.test.* class FilesTest() : BaseTest() { suspend fun testLines() { + val fileOpener = resolver.getFileOpener() + + assertFalse(fileOpener.openUserFile("non-existing.txt").exists()) + assertFalse(fileOpener.openResourceFile("non-existing.txt").exists()) + val hello = fileOpener.openResourceFile("hello.txt") var lines = hello.lines() assertEquals("Hello World!", lines[0]) assertEquals("This is a resource.", lines[1]) + val helloCopy = fileOpener.openUserFile("hello-copy.txt") + hello.copyTo(helloCopy) + lines = helloCopy.lines() + assertEquals("Hello World!", lines[0]) + assertEquals("This is a resource.", lines[1]) + + assertTrue(helloCopy.exists()) + helloCopy.delete() + assertFalse(helloCopy.exists()) + + val migration = fileOpener.openResourceFile("migrations/012.sql") + assertTrue(migration.exists()) lines = migration.lines() assertEquals("delete from Score", lines[0]) } diff --git a/core/src/jsMain/kotlin/org/isoron/platform/io/JsDatabase.kt b/core/src/jsMain/kotlin/org/isoron/platform/io/JsDatabase.kt index 89655e825..db4c2c992 100644 --- a/core/src/jsMain/kotlin/org/isoron/platform/io/JsDatabase.kt +++ b/core/src/jsMain/kotlin/org/isoron/platform/io/JsDatabase.kt @@ -72,7 +72,6 @@ class JsPreparedStatement(val stmt: dynamic) : PreparedStatement { class JsDatabase(val db: dynamic) : Database { override fun prepareStatement(sql: String): PreparedStatement { - println(sql) return JsPreparedStatement(db.prepare(sql)) } diff --git a/core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt b/core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt index 0b876d267..50b2faa3d 100644 --- a/core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt +++ b/core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt @@ -20,31 +20,111 @@ package org.isoron.platform.io import kotlinx.coroutines.* -import org.w3c.dom.events.* import org.w3c.xhr.* import kotlin.js.* -class JsFileOpener : FileOpener { - override fun openUserFile(filename: String): UserFile { - return JsUserFile(filename) +class JsFileStorage { + private val indexedDB = eval("indexedDB") + private var db: dynamic = null + + private val DB_NAME = "Main" + private val OS_NAME = "Files" + + suspend fun init() { + console.log("Initializing JsFileStorage...") + Promise { resolve, reject -> + val req = indexedDB.open(DB_NAME, 2) + req.onerror = { reject(Exception("could not open IndexedDB")) } + req.onupgradeneeded = { + console.log("Creating document store for JsFileStorage...") + req.result.createObjectStore(OS_NAME) + } + req.onsuccess = { + console.log("JsFileStorage is ready.") + db = req.result + resolve(0) + } + }.await() + } + + suspend fun delete(path: String) { + Promise { resolve, reject -> + val transaction = db.transaction(OS_NAME, "readwrite") + val os = transaction.objectStore(OS_NAME) + val req = os.delete(path) + req.onerror = { reject(Exception("could not delete $path")) } + req.onsuccess = { resolve(0) } + }.await() } - override fun openResourceFile(filename: String): ResourceFile { - return JsResourceFile(filename) + suspend fun put(path: String, content: String) { + Promise { resolve, reject -> + val transaction = db.transaction(OS_NAME, "readwrite") + val os = transaction.objectStore(OS_NAME) + val req = os.put(content, path) + req.onerror = { reject(Exception("could not put $path")) } + req.onsuccess = { resolve(0) } + }.await() + } + + suspend fun get(path: String): String { + return Promise { resolve, reject -> + val transaction = db.transaction(OS_NAME, "readonly") + val os = transaction.objectStore(OS_NAME) + val req = os.get(path) + req.onerror = { reject(Exception("could not get $path")) } + req.onsuccess = { resolve(req.result) } + }.await() + } + + suspend fun exists(path: String): Boolean { + return Promise { resolve, reject -> + val transaction = db.transaction(OS_NAME, "readonly") + val os = transaction.objectStore(OS_NAME) + val req = os.count(path) + req.onerror = { reject(Exception("could not count $path")) } + req.onsuccess = { resolve(req.result > 0) } + }.await() } } -class JsUserFile(filename: String) : UserFile { - override fun delete() { - TODO() +class JsFileOpener(val fileStorage: JsFileStorage) : FileOpener { + + override fun openUserFile(path: String): UserFile { + return JsUserFile(fileStorage, path) } - override fun exists(): Boolean { - TODO() + override fun openResourceFile(path: String): ResourceFile { + return JsResourceFile(path) + } +} + +class JsUserFile(val fs: JsFileStorage, + val filename: String) : UserFile { + override suspend fun lines(): List { + return fs.get(filename).lines() + } + + override suspend fun delete() { + fs.delete(filename) + } + + override suspend fun exists(): Boolean { + return fs.exists(filename) } } class JsResourceFile(val filename: String) : ResourceFile { + override suspend fun exists(): Boolean { + return Promise { resolve, reject -> + val xhr = XMLHttpRequest() + xhr.open("GET", "/assets/$filename", true) + xhr.onload = { resolve(xhr.status.toInt() != 404) } + xhr.onerror = { reject(Exception()) } + xhr.send() + }.await() + } + override suspend fun lines(): List { return Promise> { resolve, reject -> val xhr = XMLHttpRequest() @@ -55,7 +135,8 @@ class JsResourceFile(val filename: String) : ResourceFile { }.await() } - override fun copyTo(dest: UserFile) { - TODO() + override suspend fun copyTo(dest: UserFile) { + val fs = (dest as JsUserFile).fs + fs.put(dest.filename, lines().joinToString("\n")) } } diff --git a/core/src/jsTest/kotlin/org/isoron/DependencyResolver.kt b/core/src/jsTest/kotlin/org/isoron/DependencyResolver.kt index c3d456a3c..9f0d480db 100644 --- a/core/src/jsTest/kotlin/org/isoron/DependencyResolver.kt +++ b/core/src/jsTest/kotlin/org/isoron/DependencyResolver.kt @@ -26,7 +26,16 @@ import org.w3c.dom.* import kotlin.browser.* actual class DependencyResolver { - actual fun getFileOpener(): FileOpener = JsFileOpener() + + var fs: JsFileStorage? = null + + actual suspend fun getFileOpener(): FileOpener { + if (fs == null) { + fs = JsFileStorage() + fs?.init() + } + return JsFileOpener(fs!!) + } actual suspend fun getDatabase(): Database { val nativeDB = eval("new SQL.Database()") diff --git a/core/src/jsTest/kotlin/org/isoron/JsAsyncTests.kt b/core/src/jsTest/kotlin/org/isoron/JsAsyncTests.kt index f026c66ed..a6a1644f1 100644 --- a/core/src/jsTest/kotlin/org/isoron/JsAsyncTests.kt +++ b/core/src/jsTest/kotlin/org/isoron/JsAsyncTests.kt @@ -26,7 +26,7 @@ import kotlin.test.* class JsAsyncTests { @Test - fun testLines() = GlobalScope.promise { FilesTest().testLines() } + fun testFiles() = GlobalScope.promise { FilesTest().testLines() } @Test fun testDatabase() = GlobalScope.promise { DatabaseTest().testUsage() } diff --git a/core/src/jvmMain/kotlin/org/isoron/platform/io/JavaFiles.kt b/core/src/jvmMain/kotlin/org/isoron/platform/io/JavaFiles.kt index 7b57feace..263eaa3c6 100644 --- a/core/src/jvmMain/kotlin/org/isoron/platform/io/JavaFiles.kt +++ b/core/src/jvmMain/kotlin/org/isoron/platform/io/JavaFiles.kt @@ -22,45 +22,54 @@ package org.isoron.platform.io import java.io.* import java.nio.file.* -class JavaResourceFile(private val path: Path) : ResourceFile { +class JavaResourceFile(private val path: String) : ResourceFile { + private val javaPath: Path + get() { + val mainPath = Paths.get("assets/main/$path") + val testPath = Paths.get("assets/test/$path") + if (Files.exists(mainPath)) return mainPath + else return testPath + } + + override suspend fun exists(): Boolean { + return Files.exists(javaPath) + } + override suspend fun lines(): List { - return Files.readAllLines(path) + return Files.readAllLines(javaPath) } - override fun copyTo(dest: UserFile) { - Files.copy(path, (dest as JavaUserFile).path) + override suspend fun copyTo(dest: UserFile) { + if (dest.exists()) dest.delete() + Files.copy(javaPath, (dest as JavaUserFile).path) } fun stream(): InputStream { - return Files.newInputStream(path) + return Files.newInputStream(javaPath) } } class JavaUserFile(val path: Path) : UserFile { - override fun exists(): Boolean { + override suspend fun lines(): List { + return Files.readAllLines(path) + } + + override suspend fun exists(): Boolean { return Files.exists(path) } - override fun delete() { + override suspend fun delete() { Files.delete(path) } } class JavaFileOpener : FileOpener { - override fun openUserFile(filename: String): UserFile { - val path = Paths.get("/tmp/$filename") + override fun openUserFile(path: String): UserFile { + val path = Paths.get("/tmp/$path") return JavaUserFile(path) } - override fun openResourceFile(filename: String): ResourceFile { - val rootFolders = listOf("assets/main", - "assets/test") - for (root in rootFolders) { - val path = Paths.get("$root/$filename") - if (Files.exists(path) && Files.isReadable(path)) { - return JavaResourceFile(path) - } - } - throw RuntimeException("file not found") + override fun openResourceFile(path: String): ResourceFile { + return JavaResourceFile(path) } } diff --git a/core/src/jvmTest/kotlin/org/isoron/DependencyResolver.kt b/core/src/jvmTest/kotlin/org/isoron/DependencyResolver.kt index c8fff12f4..bc20dae1f 100644 --- a/core/src/jvmTest/kotlin/org/isoron/DependencyResolver.kt +++ b/core/src/jvmTest/kotlin/org/isoron/DependencyResolver.kt @@ -33,7 +33,7 @@ actual class DependencyResolver actual constructor() { val fileOpener = JavaFileOpener() val databaseOpener = JavaDatabaseOpener(log) - actual fun getFileOpener(): FileOpener = fileOpener + actual suspend fun getFileOpener(): FileOpener = fileOpener actual suspend fun getDatabase(): Database { val dbFile = fileOpener.openUserFile("test.sqlite3") diff --git a/core/src/jvmTest/kotlin/org/isoron/JavaAsyncTests.kt b/core/src/jvmTest/kotlin/org/isoron/JavaAsyncTests.kt index f49180335..5159a13a5 100644 --- a/core/src/jvmTest/kotlin/org/isoron/JavaAsyncTests.kt +++ b/core/src/jvmTest/kotlin/org/isoron/JavaAsyncTests.kt @@ -26,7 +26,7 @@ import org.junit.* class JavaAsyncTests { @Test - fun testLines() = runBlocking { FilesTest().testLines() } + fun testFiles() = runBlocking { FilesTest().testLines() } @Test fun testDatabase() = runBlocking { DatabaseTest().testUsage() } diff --git a/core/src/jvmTest/kotlin/org/isoron/uhabits/BaseViewTest.kt b/core/src/jvmTest/kotlin/org/isoron/uhabits/BaseViewTest.kt index 7c54db8ab..32704be35 100644 --- a/core/src/jvmTest/kotlin/org/isoron/uhabits/BaseViewTest.kt +++ b/core/src/jvmTest/kotlin/org/isoron/uhabits/BaseViewTest.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits +import kotlinx.coroutines.* import org.isoron.platform.gui.* import org.isoron.platform.io.* import org.isoron.uhabits.components.* @@ -55,28 +56,30 @@ open class BaseViewTest { expectedPath: String, component: Component, threshold: Double = 1e-3) { + val actual = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) val canvas = JavaCanvas(actual) val expectedFile: JavaResourceFile val actualPath = "/tmp/${expectedPath}" component.draw(canvas) - try { - expectedFile = JavaFileOpener().openResourceFile(expectedPath) as JavaResourceFile - } catch(e: RuntimeException) { - File(actualPath).parentFile.mkdirs() - ImageIO.write(actual, "png", File(actualPath)) - //fail("Expected file is missing. Actual render saved to $actualPath") - return - } + expectedFile = JavaFileOpener().openResourceFile(expectedPath) as JavaResourceFile - val expected = ImageIO.read(expectedFile.stream()) - val d = distance(actual, expected) - if (d >= threshold) { - File(actualPath).parentFile.mkdirs() - ImageIO.write(actual, "png", File(actualPath)) - ImageIO.write(expected, "png", File(actualPath.replace(".png", ".expected.png"))) - //fail("Images differ (distance=${d}). Actual rendered saved to ${actualPath}.") + runBlocking { + if (expectedFile.exists()) { + val expected = ImageIO.read(expectedFile.stream()) + val d = distance(actual, expected) + if (d >= threshold) { + File(actualPath).parentFile.mkdirs() + ImageIO.write(actual, "png", File(actualPath)) + ImageIO.write(expected, "png", File(actualPath.replace(".png", ".expected.png"))) + //fail("Images differ (distance=${d}). Actual rendered saved to ${actualPath}.") + } + } else { + File(actualPath).parentFile.mkdirs() + ImageIO.write(actual, "png", File(actualPath)) + //fail("Expected file is missing. Actual render saved to $actualPath") + } } } } \ No newline at end of file