Implement JsFileStorage using IndexedDB

pull/498/head
Alinson S. Xavier 7 years ago
parent f310eaf7d9
commit ddd363917c

@ -94,7 +94,6 @@ suspend fun Database.migrateTo(newVersion: Int, fileOpener: FileOpener, log: Log
for (v in (currentVersion + 1)..newVersion) { for (v in (currentVersion + 1)..newVersion) {
val sv = if(v < 10) "00$v" else if (v<100) "0$v" else "$v" val sv = if(v < 10) "00$v" else if (v<100) "0$v" else "$v"
val filename = "migrations/$sv.sql" val filename = "migrations/$sv.sql"
println(filename)
val migrationFile = fileOpener.openResourceFile(filename) val migrationFile = fileOpener.openResourceFile(filename)
for (line in migrationFile.lines()) { for (line in migrationFile.lines()) {
if (line.isEmpty()) continue if (line.isEmpty()) continue

@ -26,9 +26,11 @@ interface FileOpener {
* *
* The path is relative to the assets folder. For example, to open * The path is relative to the assets folder. For example, to open
* assets/main/migrations/09.sql you should provide migrations/09.sql * 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 * 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 * 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 * 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 * 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 * result of some user action, such as databases and logs.
* deleted.
*/ */
interface UserFile { 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<String>
} }
/** /**
* Represents a file that was shipped with the application, such as migration * 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 { 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<String> suspend fun lines(): List<String>
/**
* Returns true if the file exists.
*/
suspend fun exists(): Boolean
} }

@ -19,7 +19,6 @@
package org.isoron package org.isoron
open class BaseTest { val resolver = DependencyResolver()
val resolver = DependencyResolver()
val fileOpener = resolver.getFileOpener() open class BaseTest
}

@ -23,7 +23,7 @@ import org.isoron.platform.gui.*
import org.isoron.platform.io.* import org.isoron.platform.io.*
expect class DependencyResolver() { expect class DependencyResolver() {
fun getFileOpener(): FileOpener suspend fun getFileOpener(): FileOpener
suspend fun getDatabase(): Database suspend fun getDatabase(): Database
fun createCanvas(width: Int, height: Int): Canvas fun createCanvas(width: Int, height: Int): Canvas
fun exportCanvas(canvas: Canvas, filename: String) fun exportCanvas(canvas: Canvas, filename: String)

@ -24,12 +24,29 @@ import kotlin.test.*
class FilesTest() : BaseTest() { class FilesTest() : BaseTest() {
suspend fun testLines() { 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") val hello = fileOpener.openResourceFile("hello.txt")
var lines = hello.lines() var lines = hello.lines()
assertEquals("Hello World!", lines[0]) assertEquals("Hello World!", lines[0])
assertEquals("This is a resource.", lines[1]) 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") val migration = fileOpener.openResourceFile("migrations/012.sql")
assertTrue(migration.exists())
lines = migration.lines() lines = migration.lines()
assertEquals("delete from Score", lines[0]) assertEquals("delete from Score", lines[0])
} }

@ -72,7 +72,6 @@ class JsPreparedStatement(val stmt: dynamic) : PreparedStatement {
class JsDatabase(val db: dynamic) : Database { class JsDatabase(val db: dynamic) : Database {
override fun prepareStatement(sql: String): PreparedStatement { override fun prepareStatement(sql: String): PreparedStatement {
println(sql)
return JsPreparedStatement(db.prepare(sql)) return JsPreparedStatement(db.prepare(sql))
} }

@ -20,31 +20,111 @@
package org.isoron.platform.io package org.isoron.platform.io
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.w3c.dom.events.*
import org.w3c.xhr.* import org.w3c.xhr.*
import kotlin.js.* import kotlin.js.*
class JsFileOpener : FileOpener { class JsFileStorage {
override fun openUserFile(filename: String): UserFile { private val indexedDB = eval("indexedDB")
return JsUserFile(filename) private var db: dynamic = null
private val DB_NAME = "Main"
private val OS_NAME = "Files"
suspend fun init() {
console.log("Initializing JsFileStorage...")
Promise<Int> { 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<Int> { 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 { suspend fun put(path: String, content: String) {
return JsResourceFile(filename) Promise<Int> { 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<String> { 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<Boolean> { 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 { class JsFileOpener(val fileStorage: JsFileStorage) : FileOpener {
override fun delete() {
TODO() override fun openUserFile(path: String): UserFile {
return JsUserFile(fileStorage, path)
} }
override fun exists(): Boolean { override fun openResourceFile(path: String): ResourceFile {
TODO() return JsResourceFile(path)
}
}
class JsUserFile(val fs: JsFileStorage,
val filename: String) : UserFile {
override suspend fun lines(): List<String> {
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 { class JsResourceFile(val filename: String) : ResourceFile {
override suspend fun exists(): Boolean {
return Promise<Boolean> { 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<String> { override suspend fun lines(): List<String> {
return Promise<List<String>> { resolve, reject -> return Promise<List<String>> { resolve, reject ->
val xhr = XMLHttpRequest() val xhr = XMLHttpRequest()
@ -55,7 +135,8 @@ class JsResourceFile(val filename: String) : ResourceFile {
}.await() }.await()
} }
override fun copyTo(dest: UserFile) { override suspend fun copyTo(dest: UserFile) {
TODO() val fs = (dest as JsUserFile).fs
fs.put(dest.filename, lines().joinToString("\n"))
} }
} }

@ -26,7 +26,16 @@ import org.w3c.dom.*
import kotlin.browser.* import kotlin.browser.*
actual class DependencyResolver { 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 { actual suspend fun getDatabase(): Database {
val nativeDB = eval("new SQL.Database()") val nativeDB = eval("new SQL.Database()")

@ -26,7 +26,7 @@ import kotlin.test.*
class JsAsyncTests { class JsAsyncTests {
@Test @Test
fun testLines() = GlobalScope.promise { FilesTest().testLines() } fun testFiles() = GlobalScope.promise { FilesTest().testLines() }
@Test @Test
fun testDatabase() = GlobalScope.promise { DatabaseTest().testUsage() } fun testDatabase() = GlobalScope.promise { DatabaseTest().testUsage() }

@ -22,45 +22,54 @@ package org.isoron.platform.io
import java.io.* import java.io.*
import java.nio.file.* 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<String> { override suspend fun lines(): List<String> {
return Files.readAllLines(path) return Files.readAllLines(javaPath)
} }
override fun copyTo(dest: UserFile) { override suspend fun copyTo(dest: UserFile) {
Files.copy(path, (dest as JavaUserFile).path) if (dest.exists()) dest.delete()
Files.copy(javaPath, (dest as JavaUserFile).path)
} }
fun stream(): InputStream { fun stream(): InputStream {
return Files.newInputStream(path) return Files.newInputStream(javaPath)
} }
} }
class JavaUserFile(val path: Path) : UserFile { class JavaUserFile(val path: Path) : UserFile {
override fun exists(): Boolean { override suspend fun lines(): List<String> {
return Files.readAllLines(path)
}
override suspend fun exists(): Boolean {
return Files.exists(path) return Files.exists(path)
} }
override fun delete() { override suspend fun delete() {
Files.delete(path) Files.delete(path)
} }
} }
class JavaFileOpener : FileOpener { class JavaFileOpener : FileOpener {
override fun openUserFile(filename: String): UserFile { override fun openUserFile(path: String): UserFile {
val path = Paths.get("/tmp/$filename") val path = Paths.get("/tmp/$path")
return JavaUserFile(path) return JavaUserFile(path)
} }
override fun openResourceFile(filename: String): ResourceFile { override fun openResourceFile(path: String): ResourceFile {
val rootFolders = listOf("assets/main", return JavaResourceFile(path)
"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")
} }
} }

@ -33,7 +33,7 @@ actual class DependencyResolver actual constructor() {
val fileOpener = JavaFileOpener() val fileOpener = JavaFileOpener()
val databaseOpener = JavaDatabaseOpener(log) val databaseOpener = JavaDatabaseOpener(log)
actual fun getFileOpener(): FileOpener = fileOpener actual suspend fun getFileOpener(): FileOpener = fileOpener
actual suspend fun getDatabase(): Database { actual suspend fun getDatabase(): Database {
val dbFile = fileOpener.openUserFile("test.sqlite3") val dbFile = fileOpener.openUserFile("test.sqlite3")

@ -26,7 +26,7 @@ import org.junit.*
class JavaAsyncTests { class JavaAsyncTests {
@Test @Test
fun testLines() = runBlocking { FilesTest().testLines() } fun testFiles() = runBlocking { FilesTest().testLines() }
@Test @Test
fun testDatabase() = runBlocking { DatabaseTest().testUsage() } fun testDatabase() = runBlocking { DatabaseTest().testUsage() }

@ -19,6 +19,7 @@
package org.isoron.uhabits package org.isoron.uhabits
import kotlinx.coroutines.*
import org.isoron.platform.gui.* import org.isoron.platform.gui.*
import org.isoron.platform.io.* import org.isoron.platform.io.*
import org.isoron.uhabits.components.* import org.isoron.uhabits.components.*
@ -55,28 +56,30 @@ open class BaseViewTest {
expectedPath: String, expectedPath: String,
component: Component, component: Component,
threshold: Double = 1e-3) { threshold: Double = 1e-3) {
val actual = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) val actual = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val canvas = JavaCanvas(actual) val canvas = JavaCanvas(actual)
val expectedFile: JavaResourceFile val expectedFile: JavaResourceFile
val actualPath = "/tmp/${expectedPath}" val actualPath = "/tmp/${expectedPath}"
component.draw(canvas) component.draw(canvas)
try { expectedFile = JavaFileOpener().openResourceFile(expectedPath) as JavaResourceFile
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
}
val expected = ImageIO.read(expectedFile.stream()) runBlocking<Unit> {
val d = distance(actual, expected) if (expectedFile.exists()) {
if (d >= threshold) { val expected = ImageIO.read(expectedFile.stream())
File(actualPath).parentFile.mkdirs() val d = distance(actual, expected)
ImageIO.write(actual, "png", File(actualPath)) if (d >= threshold) {
ImageIO.write(expected, "png", File(actualPath.replace(".png", ".expected.png"))) File(actualPath).parentFile.mkdirs()
//fail("Images differ (distance=${d}). Actual rendered saved to ${actualPath}.") 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")
}
} }
} }
} }
Loading…
Cancel
Save