Finish implementing uhabits-core/jsMain

This commit is contained in:
Alinson S. Xavier 2026-04-10 23:04:48 -05:00
parent 61d8f358eb
commit aa9bd1082b
17 changed files with 106 additions and 317 deletions

View File

@ -81,6 +81,7 @@ kotlin {
dependencies {
implementation(npm("sql.js", "1.11.0"))
implementation(npm("sprintf-js", "1.1.3"))
implementation(npm("jszip", "3.10.1"))
}
}

View File

@ -1,21 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform
expect fun <T> runSuspend(block: suspend () -> T): T

View File

@ -19,6 +19,12 @@
package org.isoron.platform.io
class ZipEntry(val name: String, val content: String)
expect class ZipReader(bytes: ByteArray) {
suspend fun entries(): List<ZipEntry>
}
expect class ZipWriter() {
fun addEntry(name: String, content: String)
suspend fun toBytes(): ByteArray

View File

@ -1,26 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.io
class ZipEntry(val name: String, val content: String)
expect class ZipReader(bytes: ByteArray) {
fun entries(): List<ZipEntry>
}

View File

@ -20,7 +20,9 @@ package org.isoron.uhabits.core.tasks
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -31,6 +33,7 @@ class CoroutineTaskRunner(
private val scope = CoroutineScope(SupervisorJob() + mainDispatcher)
private val listeners = mutableListOf<TaskRunner.Listener>()
private val jobs = mutableListOf<Job>()
private var activeCount = 0
override val activeTaskCount: Int get() = activeCount
@ -45,7 +48,7 @@ class CoroutineTaskRunner(
override fun execute(task: Task) {
task.onAttached(this)
scope.launch {
val job = scope.launch {
activeCount++
listeners.forEach { it.onTaskStarted(task) }
task.onPreExecute()
@ -58,6 +61,13 @@ class CoroutineTaskRunner(
activeCount--
listeners.forEach { it.onTaskFinished(task) }
}
job.invokeOnCompletion { jobs.remove(job) }
jobs.add(job)
}
override suspend fun await() {
jobs.joinAll()
jobs.clear()
}
override fun publishProgress(task: Task, progress: Int) {

View File

@ -29,4 +29,6 @@ interface TaskRunner {
fun onTaskStarted(task: Task)
fun onTaskFinished(task: Task)
}
suspend fun await()
}

View File

@ -89,9 +89,7 @@ open class ListHabitsBehavior(
if (filename != null) {
screen.showSendFileScreen(filename)
} else {
screen.showMessage(
Message.COULD_NOT_EXPORT
)
screen.showMessage(Message.COULD_NOT_EXPORT)
}
}
)

View File

@ -19,17 +19,15 @@
package org.isoron.platform.io
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayInputStream
import java.util.zip.ZipInputStream
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ZipWriterTest {
class ZipTest {
@Test
fun testSingleEntry() = runBlocking {
fun testSingleEntry() = runTest {
val zip = ZipWriter()
zip.addEntry("hello.txt", "Hello, World!")
val bytes = zip.toBytes()
@ -40,7 +38,7 @@ class ZipWriterTest {
}
@Test
fun testMultipleEntries() = runBlocking {
fun testMultipleEntries() = runTest {
val zip = ZipWriter()
zip.addEntry("a.csv", "name,value\nfoo,1\n")
zip.addEntry("subdir/b.csv", "col1,col2\nbar,2\n")
@ -55,7 +53,7 @@ class ZipWriterTest {
}
@Test
fun testEmptyContent() = runBlocking {
fun testEmptyContent() = runTest {
val zip = ZipWriter()
zip.addEntry("empty.txt", "")
val bytes = zip.toBytes()
@ -66,7 +64,7 @@ class ZipWriterTest {
}
@Test
fun testLargeContent() = runBlocking {
fun testLargeContent() = runTest {
val zip = ZipWriter()
val large = "x".repeat(100_000)
zip.addEntry("large.txt", large)
@ -77,16 +75,7 @@ class ZipWriterTest {
assertEquals(large, entries["large.txt"])
}
private fun readZipEntries(bytes: ByteArray): Map<String, String> {
val result = mutableMapOf<String, String>()
val zis = ZipInputStream(ByteArrayInputStream(bytes))
var entry = zis.nextEntry
while (entry != null) {
result[entry.name] = zis.readBytes().decodeToString()
zis.closeEntry()
entry = zis.nextEntry
}
zis.close()
return result
private suspend fun readZipEntries(bytes: ByteArray): Map<String, String> {
return ZipReader(bytes).entries().associate { it.name to it.content }
}
}

View File

@ -95,17 +95,19 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
val outputDir = createTempDir()
every { dirFinder.getCSVOutputDir() } returns outputDir
behavior.onExportCSV()
taskRunner.await()
verify { screen.showSendFileScreen(any()) }
val files = outputDir.listFiles()
assertEquals(1, files!!.size)
}
@Test
fun testOnExportCSV_fail() {
fun testOnExportCSV_fail() = runTest {
val mockDir: UserFile = mock()
every { mockDir.resolve(any()) } throws RuntimeException("not writable")
every { dirFinder.getCSVOutputDir() } returns mockDir
behavior.onExportCSV()
taskRunner.await()
verify { screen.showMessage(ListHabitsBehavior.Message.COULD_NOT_EXPORT) }
}

View File

@ -25,6 +25,7 @@ import dev.mokkery.verify
import kotlinx.coroutines.test.runTest
import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.tasks.CoroutineTaskRunner
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@ -62,6 +63,7 @@ class ShowHabitMenuPresenterTest : BaseUnitTest() {
val outputDir = createTempDir()
every { system.getCSVOutputDir() } returns outputDir
menu.onExportCSV()
(taskRunner as CoroutineTaskRunner).await()
val files = outputDir.listFiles()
assertEquals(1, files!!.size)
}

View File

@ -1,21 +0,0 @@
package org.isoron.platform
import kotlin.coroutines.Continuation
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.startCoroutine
actual fun <T> runSuspend(block: suspend () -> T): T {
var result: Result<T>? = null
block.startCoroutine(
object : Continuation<T> {
override val context = EmptyCoroutineContext
override fun resumeWith(r: Result<T>) {
result = r
}
}
)
return result?.getOrThrow()
?: throw IllegalStateException(
"runSuspend: coroutine did not complete synchronously"
)
}

View File

@ -0,0 +1,53 @@
package org.isoron.platform.io
import kotlinx.coroutines.await
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.khronos.webgl.set
import kotlin.js.Promise
import kotlin.js.json
@JsModule("jszip")
@JsNonModule
external class JSZip {
fun loadAsync(data: dynamic): Promise<JSZip>
fun file(name: String, data: String): JSZip
fun forEach(callback: (String, dynamic) -> Unit)
fun generateAsync(options: dynamic): Promise<dynamic>
}
actual class ZipReader actual constructor(bytes: ByteArray) {
private val data = bytes
actual suspend fun entries(): List<ZipEntry> {
val uint8 = Uint8Array(data.size)
for (i in data.indices) uint8[i] = data[i]
val zip = JSZip().loadAsync(uint8).await()
val result = mutableListOf<ZipEntry>()
val files = mutableListOf<Pair<String, dynamic>>()
zip.forEach { path, file ->
if (!(file.dir as Boolean)) {
files.add(path to file)
}
}
for ((name, file) in files) {
val content = (file.async("string") as Promise<String>).await()
result.add(ZipEntry(name, content))
}
return result
}
}
actual class ZipWriter {
private val zip = JSZip()
actual fun addEntry(name: String, content: String) {
zip.file(name, content)
}
actual suspend fun toBytes(): ByteArray {
val uint8 =
(zip.generateAsync(json("type" to "uint8array", "compression" to "DEFLATE")) as Promise<Uint8Array>).await()
return ByteArray(uint8.length) { uint8[it] }
}
}

View File

@ -1,40 +0,0 @@
package org.isoron.platform.io
actual class ZipReader actual constructor(bytes: ByteArray) {
private val data = bytes
actual fun entries(): List<ZipEntry> {
val result = mutableListOf<ZipEntry>()
var pos = 0
while (pos + 30 <= data.size) {
val sig = readInt(pos)
if (sig != 0x04034b50) break
val compressionMethod = readShort(pos + 8)
val compressedSize = readInt(pos + 18)
val nameLen = readShort(pos + 26)
val extraLen = readShort(pos + 28)
val nameStart = pos + 30
val name = data.copyOfRange(nameStart, nameStart + nameLen).decodeToString()
val dataStart = nameStart + nameLen + extraLen
if (compressionMethod != 0) {
throw UnsupportedOperationException(
"Only STORED ZIP entries are supported, got method $compressionMethod"
)
}
val content = data.copyOfRange(dataStart, dataStart + compressedSize).decodeToString()
result.add(ZipEntry(name, content))
pos = dataStart + compressedSize
}
return result
}
private fun readInt(offset: Int): Int =
(data[offset].toInt() and 0xFF) or
((data[offset + 1].toInt() and 0xFF) shl 8) or
((data[offset + 2].toInt() and 0xFF) shl 16) or
((data[offset + 3].toInt() and 0xFF) shl 24)
private fun readShort(offset: Int): Int =
(data[offset].toInt() and 0xFF) or
((data[offset + 1].toInt() and 0xFF) shl 8)
}

View File

@ -1,122 +0,0 @@
package org.isoron.platform.io
actual class ZipWriter {
private val entries = mutableListOf<Pair<String, ByteArray>>()
actual fun addEntry(name: String, content: String) {
entries.add(name to content.encodeToByteArray())
}
actual suspend fun toBytes(): ByteArray {
val buf = ZipBuffer()
val offsets = mutableListOf<Int>()
for ((name, data) in entries) {
offsets.add(buf.size)
val nameBytes = name.encodeToByteArray()
val crc = crc32(data)
buf.writeInt(0x04034b50)
buf.writeShort(20)
buf.writeShort(0)
buf.writeShort(0)
buf.writeShort(0)
buf.writeShort(0)
buf.writeInt(crc)
buf.writeInt(data.size)
buf.writeInt(data.size)
buf.writeShort(nameBytes.size)
buf.writeShort(0)
buf.writeBytes(nameBytes)
buf.writeBytes(data)
}
val centralDirOffset = buf.size
for (i in entries.indices) {
val (name, data) = entries[i]
val nameBytes = name.encodeToByteArray()
val crc = crc32(data)
buf.writeInt(0x02014b50)
buf.writeShort(20)
buf.writeShort(20)
buf.writeShort(0)
buf.writeShort(0)
buf.writeShort(0)
buf.writeShort(0)
buf.writeInt(crc)
buf.writeInt(data.size)
buf.writeInt(data.size)
buf.writeShort(nameBytes.size)
buf.writeShort(0)
buf.writeShort(0)
buf.writeShort(0)
buf.writeShort(0)
buf.writeInt(0)
buf.writeInt(offsets[i])
buf.writeBytes(nameBytes)
}
val centralDirSize = buf.size - centralDirOffset
buf.writeInt(0x06054b50)
buf.writeShort(0)
buf.writeShort(0)
buf.writeShort(entries.size)
buf.writeShort(entries.size)
buf.writeInt(centralDirSize)
buf.writeInt(centralDirOffset)
buf.writeShort(0)
return buf.toByteArray()
}
}
private class ZipBuffer {
private var data = ByteArray(4096)
var size = 0
private set
fun writeShort(v: Int) {
ensureCapacity(2)
data[size++] = (v and 0xFF).toByte()
data[size++] = ((v shr 8) and 0xFF).toByte()
}
fun writeInt(v: Int) {
ensureCapacity(4)
data[size++] = (v and 0xFF).toByte()
data[size++] = ((v shr 8) and 0xFF).toByte()
data[size++] = ((v shr 16) and 0xFF).toByte()
data[size++] = ((v shr 24) and 0xFF).toByte()
}
fun writeBytes(bytes: ByteArray) {
ensureCapacity(bytes.size)
bytes.copyInto(data, size)
size += bytes.size
}
fun toByteArray(): ByteArray = data.copyOf(size)
private fun ensureCapacity(needed: Int) {
if (size + needed > data.size) {
data = data.copyOf(maxOf(data.size * 2, size + needed))
}
}
}
private val crcTable = IntArray(256).also { table ->
for (n in 0..255) {
var c = n
repeat(8) {
c = if (c and 1 != 0) (c ushr 1) xor 0xEDB88320.toInt() else c ushr 1
}
table[n] = c
}
}
private fun crc32(data: ByteArray): Int {
var crc = -1
for (b in data) {
crc = (crc ushr 8) xor crcTable[(crc xor b.toInt()) and 0xFF]
}
return crc xor -1
}

View File

@ -1,23 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform
import kotlinx.coroutines.runBlocking
actual fun <T> runSuspend(block: suspend () -> T): T = runBlocking { block() }

View File

@ -20,12 +20,14 @@
package org.isoron.platform.io
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
actual class ZipReader actual constructor(bytes: ByteArray) {
private val data = bytes
actual fun entries(): List<ZipEntry> {
actual suspend fun entries(): List<ZipEntry> {
val result = mutableListOf<ZipEntry>()
val zis = ZipInputStream(ByteArrayInputStream(data))
var entry = zis.nextEntry
@ -38,3 +40,19 @@ actual class ZipReader actual constructor(bytes: ByteArray) {
return result
}
}
actual class ZipWriter {
private val baos = ByteArrayOutputStream()
private val zos = ZipOutputStream(baos)
actual fun addEntry(name: String, content: String) {
zos.putNextEntry(java.util.zip.ZipEntry(name))
zos.write(content.toByteArray())
zos.closeEntry()
}
actual suspend fun toBytes(): ByteArray {
zos.close()
return baos.toByteArray()
}
}

View File

@ -1,39 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.io
import java.io.ByteArrayOutputStream
import java.util.zip.ZipOutputStream
actual class ZipWriter {
private val baos = ByteArrayOutputStream()
private val zos = ZipOutputStream(baos)
actual fun addEntry(name: String, content: String) {
zos.putNextEntry(java.util.zip.ZipEntry(name))
zos.write(content.toByteArray())
zos.closeEntry()
}
actual suspend fun toBytes(): ByteArray {
zos.close()
return baos.toByteArray()
}
}