Build all apps from Markdown content; Compose Desktop replaces GTK on Linux and Windows

- content/ + scripts/build-content.py produce content.json for every app
- stable lesson IDs with one-time progress migration (Swift, Kotlin)
- shared answer-check tests (tests/answers.json) for Kotlin and Swift
- android/desktop: Compose app with updater, speech and GTK progress import
- CI packages AppImage and Windows zip with ProGuard, runs the tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-09 20:40:37 +02:00
commit c3bc32113b
964 changed files with 57285 additions and 1156 deletions

View file

@ -0,0 +1,103 @@
// Linux and Windows app: the same Compose UI as Android (../shared), packaged
// with its own Java runtime by jpackage.
plugins {
id("org.jetbrains.kotlin.jvm")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
}
val commit: String = System.getenv("COMMIT")?.takeIf { it.isNotBlank() } ?: "dev"
val repoRoot: File = rootProject.projectDir.parentFile
val generated = layout.buildDirectory.dir("generated/resources")
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
sourceSets.named("main") {
kotlin.srcDir("../shared/src")
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets.named("main") {
resources.srcDir(generated)
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3")
implementation("org.json:json:20250517")
testImplementation(kotlin("test"))
}
val buildContent = tasks.register<Exec>("buildContent") {
val out = generated.get().file("content.json").asFile
workingDir = repoRoot
val python = System.getenv("PYTHON")?.takeIf { it.isNotBlank() }
?: if (System.getProperty("os.name").startsWith("Windows")) "python" else "python3"
commandLine(python, "scripts/build-content.py", "--out", out.path)
inputs.dir(repoRoot.resolve("content"))
inputs.file(repoRoot.resolve("scripts/build-content.py"))
outputs.file(out)
}
val buildInfo = tasks.register("buildInfo") {
val out = generated.get().file("build-info.properties").asFile
inputs.property("commit", commit)
outputs.file(out)
doLast {
out.parentFile.mkdirs()
out.writeText("commit=$commit\n")
}
}
val appIcon = tasks.register<Copy>("appIcon") {
from(repoRoot.resolve("data/share/icons/hicolor/256x256/apps/maturita.png"))
into(generated)
rename { "icon.png" }
}
tasks.named("processResources") {
dependsOn(buildContent, buildInfo, appIcon)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
systemProperty("graduately.repo", repoRoot.path)
}
compose.desktop {
application {
mainClass = "org.maturita.maturita.desktop.MainKt"
// jpackage and ProGuard need a JDK with jmods (Temurin 21 has them, 24+ may not).
System.getenv("PACKAGE_JDK")?.takeIf { it.isNotBlank() }?.let { javaHome = it }
// Shrinking drops the thousands of unused icons and keeps the
// packages under GitHub's 100 MB file limit.
buildTypes.release.proguard {
version.set("7.10.0")
obfuscate.set(false)
configurationFiles.from(project.file("proguard-rules.pro"))
}
nativeDistributions {
// The launcher must stay graduately(.exe): installers and the
// updater of older builds start it by that name.
packageName = "graduately"
packageVersion = "1.0.0"
description = "Graduately"
vendor = "Graduately"
modules("java.naming", "jdk.crypto.ec", "jdk.unsupported")
linux {
iconFile.set(repoRoot.resolve("data/share/icons/hicolor/512x512/apps/maturita.png"))
}
windows {
iconFile.set(repoRoot.resolve("assets/app-icon.ico"))
}
}
}
}

2
android/desktop/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,2 @@
-dontwarn org.slf4j.**
-dontnote **

View file

@ -0,0 +1,17 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.Platform
import org.maturita.maturita.platform.Prefs
import org.maturita.maturita.platform.Speaker
class DesktopPlatform(
override val commit: String,
override val prefs: Prefs,
install: Install,
) : Platform {
override val versionName: String = if (Install.isWindows) "windows" else "linux"
override val welcomeKey = "welcome_body_desktop"
override val updater = DesktopUpdater(commit, install)
override fun speaker(): Speaker? = DesktopSpeaker.create()
}

View file

@ -0,0 +1,99 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.Speaker
import java.io.File
import java.util.Locale
import javax.swing.SwingUtilities
import kotlin.concurrent.thread
/**
* Speech through whatever the system already has: SAPI via PowerShell on
* Windows, espeak-ng / espeak / spd-say on Linux, `say` on a Mac. None of them
* report word positions, so pause and resume start the passage again.
*/
class DesktopSpeaker private constructor(private val command: (String, Locale, Float, Float) -> List<String>) : Speaker {
@Volatile private var process: Process? = null
override fun speak(
text: String,
locale: Locale,
rate: Float,
volume: Float,
onRange: (Int) -> Unit,
onEnd: (Boolean) -> Unit,
) {
stop()
val argv = command(text, locale, rate, volume)
val stdin = argv.lastOrNull() == STDIN
val p = runCatching {
ProcessBuilder(if (stdin) argv.dropLast(1) else argv)
.redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.start()
}.getOrElse {
SwingUtilities.invokeLater { onEnd(false) }
return
}
process = p
thread(isDaemon = true, name = "speech") {
if (stdin) runCatching { p.outputStream.writer(Charsets.UTF_8).use { it.write(text) } }
val ok = p.waitFor() == 0
if (process === p) {
process = null
SwingUtilities.invokeLater { onEnd(ok) }
}
}
}
override fun stop() {
process?.let {
process = null
it.destroy()
}
}
override fun close() = stop()
companion object {
/** Marks a command that reads the text from standard input. */
private const val STDIN = "\u0000stdin"
private fun onPath(name: String): Boolean =
System.getenv("PATH").orEmpty().split(File.pathSeparator).any { File(it, name).canExecute() }
fun create(): DesktopSpeaker? {
val os = System.getProperty("os.name").orEmpty()
return when {
os.startsWith("Windows") -> DesktopSpeaker { _, locale, rate, volume ->
val sapiRate = ((rate - 1f) * 10f).toInt().coerceIn(-10, 10)
val script = "[Console]::InputEncoding=[Text.Encoding]::UTF8;" +
"Add-Type -AssemblyName System.Speech;" +
"\$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" +
"try{\$s.SelectVoiceByHints('NotSet','NotSet',0,[Globalization.CultureInfo]'${locale.toLanguageTag()}')}catch{};" +
"\$s.Rate=$sapiRate;\$s.Volume=${(volume * 100).toInt()};" +
"\$s.Speak([Console]::In.ReadToEnd())"
listOf("powershell", "-NoProfile", "-NonInteractive", "-Command", script, STDIN)
}
os.startsWith("Mac") -> DesktopSpeaker { text, _, rate, _ ->
listOf("say", "-r", (180 * rate).toInt().toString(), text)
}
onPath("espeak-ng") || onPath("espeak") -> {
val bin = if (onPath("espeak-ng")) "espeak-ng" else "espeak"
DesktopSpeaker { _, locale, rate, volume ->
val voice = if (locale.language == "de") "de" else "en-gb"
listOf(bin, "-v", voice, "-s", (165 * rate).toInt().toString(), "-a", (volume * 150).toInt().toString(), "--stdin", STDIN)
}
}
onPath("spd-say") -> DesktopSpeaker { text, locale, rate, volume ->
listOf(
"spd-say", "-w", "-l", locale.language,
"-r", ((rate - 1f) * 100f).toInt().coerceIn(-100, 100).toString(),
"-i", ((volume - 1f) * 100f).toInt().coerceIn(-100, 100).toString(),
text,
)
}
else -> null
}
}
}
}

View file

@ -0,0 +1,220 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.AppUpdater
import org.maturita.maturita.platform.UpdateState
import java.io.File
import java.net.HttpURLConnection
import java.net.URI
import java.util.concurrent.Executors
import java.util.zip.ZipInputStream
import javax.swing.SwingUtilities
import kotlin.system.exitProcess
/**
* Self-update from the `builds` branch, the same scheme the GTK builds used,
* so their updater and this one hand over cleanly: the asset names stay, and
* progress/ next to the app survives the swap.
*
* raw.githubusercontent.com caches branch-named URLs, so the branch tip is
* read from the Atom feed and every file is fetched from that commit. A running
* app cannot replace itself; a small script waits for this process to exit,
* swaps the new version in and starts it.
*/
class DesktopUpdater(private val commit: String, private val install: Install) : AppUpdater {
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "updater").apply { isDaemon = true } }
private val repo = "pepaskopec-blip/Graduately"
private val branch = "builds"
private val platform = if (Install.isWindows) "windows" else "linux"
private val asset = if (Install.isWindows) "graduately-windows-x64.zip" else "graduately-linux-x86_64.AppImage"
private fun emit(cb: (UpdateState) -> Unit, state: UpdateState) = SwingUtilities.invokeLater { cb(state) }
override fun check(interactive: Boolean, cb: (UpdateState) -> Unit) {
if (!commit.matches(Regex("[0-9a-fA-F]{7,40}"))) {
emit(cb, UpdateState("dev", messageKey = "update_err_devbuild"))
return
}
if (interactive) emit(cb, UpdateState("checking", messageKey = "update_checking"))
io.execute {
try {
val atom = get("https://github.com/$repo/commits/$branch.atom")
val tip = atomTip(atom) ?: error("no tip")
val remote = remoteCommit(atom, tip)
emit(cb, when {
sameCommit(remote, commit) -> UpdateState("ok", remote, "update_uptodate", remote.take(7))
install.kind == Install.Kind.Unsupported ->
UpdateState("unsupported", remote, if (interactive) "update_err_unsupported" else "update_current", commit.take(7))
else -> UpdateState("available", remote, "update_available", remote.take(7), canInstall = true)
})
} catch (_: Exception) {
emit(cb, if (interactive) UpdateState("error", messageKey = "update_err_network")
else UpdateState(messageArg = commit.take(7)))
}
}
}
override fun install(cb: (UpdateState) -> Unit) {
val target = install.target
if (install.kind == Install.Kind.Unsupported || target == null) {
emit(cb, UpdateState("error", messageKey = "update_err_unsupported"))
return
}
emit(cb, UpdateState("downloading", messageKey = "update_downloading"))
io.execute {
val staging = File(System.getProperty("java.io.tmpdir"), "graduately-update")
val source: File
try {
val tip = atomTip(get("https://github.com/$repo/commits/$branch.atom")) ?: error("no tip")
staging.deleteRecursively()
staging.mkdirs()
val file = File(staging, asset)
download("https://raw.githubusercontent.com/$repo/$tip/$asset", file)
source = if (install.kind == Install.Kind.WindowsDir) {
File(staging, "new").also { unzip(file, it) }
.takeIf { File(it, "graduately.exe").isFile } ?: error("bad zip")
} else {
file.takeIf { isElf(it) }?.also { it.setExecutable(true) } ?: error("bad AppImage")
}
} catch (_: Exception) {
staging.deleteRecursively()
emit(cb, UpdateState("error", messageKey = "update_err_download", canInstall = true))
return@execute
}
try {
startSwap(target, source, staging)
} catch (_: Exception) {
emit(cb, UpdateState("error", messageKey = "update_err_stage", canInstall = true))
return@execute
}
emit(cb, UpdateState("staged", messageKey = "update_staged"))
SwingUtilities.invokeLater { exitProcess(0) }
}
}
private fun startSwap(target: File, source: File, staging: File) {
val pid = ProcessHandle.current().pid().toString()
val windows = install.kind == Install.Kind.WindowsDir
val script = File(System.getProperty("java.io.tmpdir"), if (windows) "graduately-update.cmd" else "graduately-update.sh")
script.writeText(if (windows) WINDOWS_SWAP else UNIX_SWAP)
script.setExecutable(true)
val argv = if (windows) listOf("cmd", "/c", script.path) else listOf("/bin/sh", script.path)
val pb = ProcessBuilder(argv + listOf(pid, target.path, source.path, staging.path))
// The new AppImage must get its own mount, not inherit this one.
pb.environment().keys.removeAll(setOf("APPIMAGE", "APPDIR", "ARGV0", "OWD"))
val p = pb.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start()
// The script detaches itself and exits; wait so we do not quit first.
p.waitFor()
}
/** Commit whose package for this platform sits on the branch. */
private fun remoteCommit(atom: String, tip: String): String {
val marker = runCatching { get("https://raw.githubusercontent.com/$repo/$tip/VERSION-$platform").trim() }.getOrNull()
if (marker != null && marker.length >= 7) return marker
return atomMainSha(atom) ?: get("https://raw.githubusercontent.com/$repo/$tip/VERSION").trim()
}
private fun atomTip(atom: String): String? =
Regex("""Commit/([0-9a-f]{40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
private fun atomMainSha(atom: String): String? =
Regex("""Build from ([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
private fun sameCommit(a: String, b: String): Boolean {
val n = minOf(a.length, b.length)
return n >= 7 && a.take(n).equals(b.take(n), ignoreCase = true)
}
private fun isElf(f: File): Boolean = f.length() > 1_000_000L &&
f.inputStream().use { val h = ByteArray(4); it.read(h) == 4 && h.contentEquals(byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())) }
private fun unzip(zip: File, dest: File) {
val root = dest.canonicalFile
ZipInputStream(zip.inputStream().buffered()).use { z ->
while (true) {
val e = z.nextEntry ?: break
val out = File(root, e.name).canonicalFile
if (!out.path.startsWith(root.path + File.separator)) error("zip slip")
if (e.isDirectory) out.mkdirs() else {
out.parentFile.mkdirs()
out.outputStream().use { z.copyTo(it) }
}
}
}
}
private fun open(url: String, readMs: Int): HttpURLConnection =
(URI(url).toURL().openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = readMs
instanceFollowRedirects = true
setRequestProperty("User-Agent", "Graduately-desktop")
}
private fun get(url: String): String {
val c = open(url, 15000)
try {
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
}
}
private fun download(url: String, dest: File) {
val c = open(url, 60000)
try {
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
c.inputStream.use { input -> dest.outputStream().use { input.copyTo(it) } }
} finally {
c.disconnect()
}
}
private companion object {
// Arguments: pid target source staging.
const val UNIX_SWAP = """#!/bin/sh
if [ -z "${'$'}GRADUATELY_UPDATE_DETACHED" ]; then
GRADUATELY_UPDATE_DETACHED=1
export GRADUATELY_UPDATE_DETACHED
nohup /bin/sh "${'$'}0" "${'$'}@" >/dev/null 2>&1 &
exit 0
fi
pid=${'$'}1; target=${'$'}2; source=${'$'}3; staging=${'$'}4
n=0
while kill -0 "${'$'}pid" 2>/dev/null && [ ${'$'}n -lt 600 ]; do
sleep 0.2; n=${'$'}((n+1))
done
if mv -f "${'$'}source" "${'$'}target" 2>/dev/null; then
chmod +x "${'$'}target"
fi
"${'$'}target" >/dev/null 2>&1 &
rm -rf "${'$'}staging"
rm -f "${'$'}0"
"""
// robocopy mirrors the new build over the old one (so nothing stale is
// left behind) but never touches progress/. ping is a sleep without a
// console window.
val WINDOWS_SWAP = """@echo off
if not defined GRADUATELY_UPDATE_DETACHED (
set GRADUATELY_UPDATE_DETACHED=1
start "" /b cmd /c "%~f0" %*
exit /b 0
)
set "PID=%~1"
set "TARGET=%~2"
set "SOURCE=%~3"
set "STAGING=%~4"
:wait
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if not errorlevel 1 (
ping -n 2 127.0.0.1 >nul
goto wait
)
robocopy "%SOURCE%" "%TARGET%" /MIR /XD progress /R:5 /W:1 /NFL /NDL /NJH /NJS /NP >nul
start "" "%TARGET%\graduately.exe"
rmdir /s /q "%STAGING%"
(goto) 2>nul & del "%~f0"
""".replace("\n", "\r\n")
}
}

View file

@ -0,0 +1,46 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.Prefs
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Properties
/** Settings and progress in one properties file, rewritten atomically on every change. */
class FilePrefs(private val file: File) : Prefs {
private val values = Properties()
init {
runCatching { file.inputStream().use { values.load(it.reader(Charsets.UTF_8)) } }
}
override fun getBoolean(key: String, fallback: Boolean) =
values.getProperty(key)?.toBooleanStrictOrNull() ?: fallback
override fun getInt(key: String, fallback: Int) =
values.getProperty(key)?.toIntOrNull() ?: fallback
override fun getString(key: String, fallback: String?): String? =
values.getProperty(key) ?: fallback
override fun edit(): Prefs.Editor = Editor()
@Synchronized
private fun save(changes: Map<String, String>) {
values.putAll(changes)
runCatching {
file.parentFile?.mkdirs()
val tmp = File(file.path + ".tmp")
tmp.writer(Charsets.UTF_8).use { values.store(it, null) }
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
}
}
private inner class Editor : Prefs.Editor {
private val changes = LinkedHashMap<String, String>()
override fun putBoolean(key: String, value: Boolean) = apply { changes[key] = value.toString() }
override fun putInt(key: String, value: Int) = apply { changes[key] = value.toString() }
override fun putString(key: String, value: String) = apply { changes[key] = value }
override fun apply() = save(changes)
}
}

View file

@ -0,0 +1,96 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.data.ThemeId
import org.maturita.maturita.data.themeNames
import org.maturita.maturita.platform.Prefs
import java.io.File
/**
* The GTK builds kept progress in `progress/<course>.conf` key files. Copy it
* once into the shared progress keys (`net.3`, `en.2.5`, `g.0.vocab` …); the
* shared ProgressStore then moves those onto lesson ids like on every
* platform. The .conf files stay, so nothing is lost if the import misreads.
*/
fun importGtkProgress(dir: File, prefs: Prefs) {
if (prefs.getBoolean("gtk_imported", false)) return
val edit = prefs.edit()
for (file in dir.listFiles { f -> f.name.endsWith(".conf") }.orEmpty()) {
val course = file.name.removeSuffix(".conf")
val groups = readKeyFile(file)
if (course == "settings") {
groups["ui"]?.let { importSettings(it, edit) }
continue
}
for ((key, value) in groups["done"].orEmpty()) {
if (value != "true") continue
gtkAddress(course, key)?.let { edit.putBoolean(it, true) }
}
}
edit.putBoolean("gtk_imported", true).apply()
}
/** GTK file and key to the address the apps use, or null for what has no equivalent. */
internal fun gtkAddress(course: String, key: String): String? {
val unit = Regex("""unit(\d)""").matchEntire(course)?.groupValues?.get(1)?.toInt()
return when {
// The vocabulary branch used the last slot of the unit.
unit != null -> key.toIntOrNull()?.let { if (it == 20) "g.${unit - 1}.vocab" else "g.${unit - 1}.$it" }
course == "english" -> yearKey("en", key)
course == "german-course" -> yearKey("de", key)
course == "mluvnice" -> key.toIntOrNull()?.let { "mluv.$it" }
// Book progress was one shared slot, not per book.
course == "cetba" -> null
else -> key.toIntOrNull()?.let { "$course.$it" }
}
}
private fun yearKey(prefix: String, key: String): String? {
val (year, lesson) = key.split("-").takeIf { it.size == 2 } ?: return null
if (year.toIntOrNull() == null || lesson.toIntOrNull() == null) return null
return "$prefix.$year.$lesson"
}
private fun importSettings(ui: Map<String, String>, edit: Prefs.Editor) {
ui["theme"]?.let { name ->
val i = themeNames.indexOfFirst { it.equals(name, ignoreCase = true) }
if (i >= 0) edit.putInt("theme", ThemeId.entries[i].ordinal)
}
ui["mode"]?.let { edit.putString("mode", if (it.lowercase() in setOf("light", "white")) "light" else "dark") }
ui["lang"]?.let { edit.putString("lang", if (it.lowercase() in setOf("en", "english")) "en" else "cs") }
ui["seen_commit"]?.takeIf { it.isNotBlank() }?.let { edit.putString("seen_commit", it) }
}
internal fun readKeyFile(file: File): Map<String, Map<String, String>> {
val groups = LinkedHashMap<String, MutableMap<String, String>>()
var current: MutableMap<String, String>? = null
runCatching { file.readLines() }.getOrDefault(emptyList()).forEach { raw ->
val line = raw.trim()
when {
line.isEmpty() || line.startsWith("#") -> {}
line.startsWith("[") && line.endsWith("]") ->
current = groups.getOrPut(line.substring(1, line.length - 1)) { LinkedHashMap() }
'=' in line -> current?.put(line.substringBefore('=').trim(), line.substringAfter('=').trim())
}
}
return groups
}
/**
* The GTK updater copied new packages over the install without deleting
* anything, so a Windows folder updated from GTK still carries its DLLs and
* data. style.css marks such a folder; jpackage never writes one.
*/
fun removeGtkLeftovers(install: Install) {
if (install.kind != Install.Kind.WindowsDir) return
val dir = install.target ?: return
val marker = File(dir, "style.css")
if (!marker.isFile) return
dir.listFiles().orEmpty().forEach { f ->
val name = f.name.lowercase()
val gtkDll = name.endsWith(".dll") && (name.startsWith("lib") || name == "zlib1.dll")
val stale = (f.isFile && (gtkDll || name == "changelog.txt")) ||
(f.isDirectory && name in setOf("lib", "share", "icons"))
if (stale) f.deleteRecursively()
}
marker.delete()
}

View file

@ -0,0 +1,40 @@
package org.maturita.maturita.desktop
import java.io.File
/**
* How this copy runs, which decides where progress lives and what an update
* replaces. Progress sits next to the app, as it did in the GTK builds, so
* existing students keep it: beside the AppImage on Linux, inside the install
* directory on Windows.
*/
class Install(
val kind: Kind,
/** The AppImage file, or the Windows directory holding graduately.exe. */
val target: File?,
val progressDir: File,
) {
enum class Kind { AppImage, WindowsDir, Unsupported }
companion object {
val isWindows = System.getProperty("os.name").orEmpty().startsWith("Windows")
fun detect(): Install {
val appImage = System.getenv("APPIMAGE")?.takeIf { it.isNotBlank() }?.let(::File)
val launcher = System.getProperty("jpackage.app-path")?.let(::File)
val windowsDir = launcher?.parentFile?.takeIf {
isWindows && launcher.name.equals("graduately.exe", ignoreCase = true) &&
File(it, "app").isDirectory && File(it, "runtime").isDirectory
}
val (kind, target) = when {
appImage != null && !isWindows -> Kind.AppImage to appImage
windowsDir != null -> Kind.WindowsDir to windowsDir
else -> Kind.Unsupported to null
}
val beside = target?.let { if (kind == Kind.AppImage) it.parentFile else it }
val home = File(System.getProperty("user.home"), ".graduately")
val root = beside?.takeIf { it.canWrite() } ?: home
return Install(kind, target, File(root, "progress"))
}
}
}

View file

@ -0,0 +1,72 @@
package org.maturita.maturita.desktop
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import org.maturita.maturita.AppViewModel
import org.maturita.maturita.MaturitaApp
import org.maturita.maturita.data.Content
import org.maturita.maturita.handleBack
import java.io.File
import java.util.Properties
import javax.imageio.ImageIO
private fun resourceText(name: String): String? =
Thread.currentThread().contextClassLoader.getResourceAsStream(name)?.bufferedReader()?.use { it.readText() }
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val install = Install.detect()
val prefs = FilePrefs(File(install.progressDir, "graduately.properties"))
importGtkProgress(install.progressDir, prefs)
removeGtkLeftovers(install)
val info = Properties().apply { resourceText("build-info.properties")?.let { load(it.reader()) } }
val platform = DesktopPlatform(info.getProperty("commit") ?: "dev", prefs, install)
val vm = AppViewModel(Content.parse(resourceText("content.json")), platform)
val icon = Thread.currentThread().contextClassLoader.getResourceAsStream("icon.png")
?.use { ImageIO.read(it) }?.let { BitmapPainter(it.toComposeImageBitmap()) }
application {
Window(
onCloseRequest = ::exitApplication,
title = "Graduately",
icon = icon,
state = rememberWindowState(width = 1100.dp, height = 780.dp),
onPreviewKeyEvent = { e ->
if (e.type != KeyEventType.KeyDown) return@Window false
val cmd = e.isCtrlPressed || e.isMetaPressed
when {
e.key == Key.Escape -> vm.handleBack()
cmd && e.key == Key.K -> { vm.searchOpen = true; true }
cmd && e.key == Key.Q -> { exitApplication(); true }
else -> false
}
},
) {
Box(
Modifier.fillMaxSize().onPointerEvent(PointerEventType.Press) { e ->
if (e.button == PointerButton.Back) vm.handleBack()
},
) {
MaturitaApp(vm)
}
}
}
}

View file

@ -0,0 +1,86 @@
package org.maturita.maturita
import org.json.JSONArray
import org.json.JSONObject
import org.maturita.maturita.data.answerAccepts
import org.maturita.maturita.data.answerIncomplete
import org.maturita.maturita.data.hasUmlaut
import org.maturita.maturita.data.mathAnswerOk
import org.maturita.maturita.data.mathDrawGiven
import org.maturita.maturita.data.mathDrawNeed
import org.maturita.maturita.data.mathDrawOk
import org.maturita.maturita.data.mathIsDraw
import org.maturita.maturita.data.netTxtEq
import org.maturita.maturita.data.normalizeAnswer
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Runs tests/answers.json, the cases the Swift apps check too (tests/swift). */
class AnswersTest {
private val cases = JSONObject(File(System.getProperty("graduately.repo"), "tests/answers.json").readText())
private fun rows(name: String): List<JSONArray> =
cases.getJSONArray(name).let { a -> (0 until a.length()).map { a.getJSONArray(it) } }
private fun JSONArray.str(i: Int): String? = if (isNull(i)) null else getString(i)
private fun JSONArray.points(i: Int): List<Pair<Int, Int>> =
getJSONArray(i).let { a -> (0 until a.length()).map { a.getJSONArray(it).let { p -> p.getInt(0) to p.getInt(1) } } }
@Test
fun normalize() = rows("normalize").forEach { assertEquals(it.str(1), normalizeAnswer(it.str(0)), "normalize ${it.str(0)}") }
@Test
fun accepts() = rows("accepts").forEach {
assertEquals(it.getBoolean(2), answerAccepts(normalizeAnswer(it.str(0)), it.str(1)), "accepts $it")
}
@Test
fun incomplete() = rows("incomplete").forEach {
assertEquals(it.getBoolean(2), answerIncomplete(normalizeAnswer(it.str(0)), it.str(1)), "incomplete $it")
}
@Test
fun netText() = rows("netTxtEq").forEach { assertEquals(it.getBoolean(2), netTxtEq(it.str(0), it.str(1)), "netTxtEq $it") }
@Test
fun umlaut() = rows("hasUmlaut").forEach { assertEquals(it.getBoolean(1), hasUmlaut(it.str(0)), "hasUmlaut $it") }
@Test
fun math() = rows("math").forEach { assertEquals(it.getBoolean(2), mathAnswerOk(it.str(0), it.str(1)), "math $it") }
@Test
fun draw() {
rows("drawNeed").forEach { assertEquals(it.getInt(1), mathDrawNeed(it.str(0)), "drawNeed $it") }
rows("drawGiven").forEach { assertEquals(it.points(1), mathDrawGiven(it.str(0)), "drawGiven $it") }
rows("draw").forEach { assertEquals(it.getBoolean(2), mathDrawOk(it.str(0), it.points(1)), "draw $it") }
}
/** Every worked answer in content/ must pass its own check, or students could never solve it. */
@Test
fun contentAnswersSolveThemselves() {
val root = JSONObject(javaClass.classLoader.getResource("content.json")!!.readText())
var checked = 0
for (section in listOf("mat0", "mat", "mat2", "mat3", "mat4")) {
val lessons = root.getJSONArray(section)
for (l in 0 until lessons.length()) {
val lesson = lessons.getJSONObject(l)
val problems = lesson.getJSONArray("problems")
for (p in 0 until problems.length()) {
val spec = problems.getJSONObject(p).getString("answer")
val where = "$section ${lesson.getString("key")}: $spec"
if (mathIsDraw(spec)) {
assertTrue(mathDrawNeed(spec) > 0, where)
} else {
val body = spec.substringAfter(':').substringBefore('|')
assertTrue(mathAnswerOk(spec, body), where)
}
checked++
}
}
}
assertTrue(checked > 100, "only $checked problems found")
}
}

View file

@ -0,0 +1,77 @@
package org.maturita.maturita
import org.json.JSONObject
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.data.Content
import org.maturita.maturita.data.ProgressStore
import org.maturita.maturita.data.ThemeId
import org.maturita.maturita.desktop.FilePrefs
import org.maturita.maturita.desktop.gtkAddress
import org.maturita.maturita.desktop.importGtkProgress
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ProgressTest {
private val content = Content.parse(javaClass.classLoader.getResource("content.json")!!.readText())
@Test
fun everyLegacyAddressHasALesson() {
val root = JSONObject(javaClass.classLoader.getResource("content.json")!!.readText())
val keys = root.getJSONObject("progress").getJSONObject("keys")
assertTrue(content.progressLegacy.isNotEmpty())
for ((old, id) in content.progressLegacy) {
assertTrue(keys.keySet().any { keys.getString(it) == id }, "$old -> $id has no lesson")
}
}
@Test
fun oldProgressMovesToLessonIds() {
val dir = Files.createTempDirectory("progress").toFile()
val prefs = FilePrefs(dir.resolve("p.properties"))
prefs.edit().putBoolean("fyz.1", true).putBoolean("g.0.vocab", true).putBoolean("en.2.3", true).apply()
val store = ProgressStore(prefs, content)
assertTrue(store.fyzDone(1))
assertTrue(store.vocabDone(0))
assertTrue(store.enDone(2, 3))
assertFalse(store.fyzDone(2))
assertTrue(prefs.getBoolean("done.fyzika-1-fyzikalni-veliciny", false))
store.markMat(1)
assertTrue(FilePrefs(dir.resolve("p.properties")).getBoolean("done.matematika-1-delitelnost-a-prvocisla", false))
}
@Test
fun gtkProgressIsImported() {
val dir = Files.createTempDirectory("gtk").toFile()
dir.resolve("net.conf").writeText("[done]\n1=true\n2=false\n")
dir.resolve("unit1.conf").writeText("[done]\n1=true\n20=true\n")
dir.resolve("english.conf").writeText("[done]\n2-3=true\n")
dir.resolve("settings.conf").writeText("[ui]\ntheme=Nord\nmode=dark\nlang=en\nseen_commit=abcdef1\n")
val prefs = FilePrefs(dir.resolve("graduately.properties"))
importGtkProgress(dir, prefs)
val store = ProgressStore(prefs, content)
assertTrue(store.netDone(1))
assertFalse(store.netDone(2))
assertTrue(store.germanDone(0, 1))
assertTrue(store.vocabDone(0))
assertTrue(store.enDone(2, 3))
assertEquals(ThemeId.Nord, store.themeId)
assertEquals(ColorMode.Dark, store.mode)
assertEquals("abcdef1", store.seenCommit)
}
@Test
fun gtkAddresses() {
assertEquals("g.2.5", gtkAddress("unit3", "5"))
assertEquals("de.4.6", gtkAddress("german-course", "4-6"))
assertEquals("mluv.7", gtkAddress("mluvnice", "7"))
assertEquals("hw3.2", gtkAddress("hw3", "2"))
assertEquals(null, gtkAddress("cetba", "1"))
}
}