mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 09:08:52 -06:00
Implement JsFileStorage using IndexedDB
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<String>
|
||||
|
||||
/**
|
||||
* Returns true if the file exists.
|
||||
*/
|
||||
suspend fun exists(): Boolean
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
package org.isoron
|
||||
|
||||
open class BaseTest {
|
||||
val resolver = DependencyResolver()
|
||||
val fileOpener = resolver.getFileOpener()
|
||||
}
|
||||
|
||||
open class BaseTest
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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()
|
||||
}
|
||||
|
||||
override fun openResourceFile(filename: String): ResourceFile {
|
||||
return JsResourceFile(filename)
|
||||
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()
|
||||
}
|
||||
|
||||
suspend fun put(path: String, content: String) {
|
||||
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 {
|
||||
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<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 {
|
||||
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> {
|
||||
return Promise<List<String>> { 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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()")
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -22,45 +22,54 @@ package org.isoron.platform.io
|
||||
import java.io.*
|
||||
import java.nio.file.*
|
||||
|
||||
class JavaResourceFile(private val path: Path) : ResourceFile {
|
||||
override suspend fun lines(): List<String> {
|
||||
return Files.readAllLines(path)
|
||||
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 fun copyTo(dest: UserFile) {
|
||||
Files.copy(path, (dest as JavaUserFile).path)
|
||||
override suspend fun exists(): Boolean {
|
||||
return Files.exists(javaPath)
|
||||
}
|
||||
|
||||
override suspend fun lines(): List<String> {
|
||||
return Files.readAllLines(javaPath)
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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)) {
|
||||
override fun openResourceFile(path: String): ResourceFile {
|
||||
return JavaResourceFile(path)
|
||||
}
|
||||
}
|
||||
throw RuntimeException("file not found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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,21 +56,17 @@ 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
|
||||
}
|
||||
|
||||
runBlocking<Unit> {
|
||||
if (expectedFile.exists()) {
|
||||
val expected = ImageIO.read(expectedFile.stream())
|
||||
val d = distance(actual, expected)
|
||||
if (d >= threshold) {
|
||||
@@ -78,5 +75,11 @@ open class BaseViewTest {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user