mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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:
parent
b324496334
commit
c3bc32113b
964 changed files with 57285 additions and 1156 deletions
103
android/desktop/build.gradle.kts
Normal file
103
android/desktop/build.gradle.kts
Normal 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
2
android/desktop/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-dontwarn org.slf4j.**
|
||||
-dontnote **
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue