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

@ -131,7 +131,7 @@
A701000000000000000001 /* Sources */,
A401000000000000000001 /* Frameworks */,
A701000000000000000002 /* Resources */,
A701000000000000000004 /* Extract content */,
A701000000000000000004 /* Build content */,
);
buildRules = (
);
@ -188,7 +188,7 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A701000000000000000004 /* Extract content */ = {
A701000000000000000004 /* Build content */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@ -198,7 +198,7 @@
);
inputPaths = (
);
name = "Extract content";
name = "Build content";
outputFileListPaths = (
);
outputPaths = (
@ -207,7 +207,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# Pull the exercises out of the C sources and drop the JSON into the app\n# bundle. Nothing generated is kept in the repository, so a fresh clone opens\n# and runs in Xcode without extra steps.\nset -eu\nextract=\"${SRCROOT}/../scripts/extract-android-content.py\"\nif [ ! -f \"$extract\" ]; then\n echo \"error: ${extract} not found. Keep ios/ inside the Graduately checkout.\" >&2\n exit 1\nfi\nout=\"${DERIVED_FILE_DIR}/content.json\"\nmkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"$extract\" --out \"$out\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"$out\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content.json\"\n";
shellScript = "# Build content.json from the Markdown in content/ and drop it into the app\n# bundle. Nothing generated is kept in the repository, so a fresh clone opens\n# and runs in Xcode without extra steps.\nset -eu\nextract=\"${SRCROOT}/../scripts/build-content.py\"\nif [ ! -f \"$extract\" ]; then\n echo \"error: ${extract} not found. Keep ios/ inside the Graduately checkout.\" >&2\n exit 1\nfi\nout=\"${DERIVED_FILE_DIR}/content.json\"\nmkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"$extract\" --out \"$out\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"$out\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content.json\"\n";
};
/* End PBXShellScriptBuildPhase section */

View file

@ -2,8 +2,8 @@ import Foundation
import SwiftUI
final class AppModel: ObservableObject {
let content = Content.load()
let progress = ProgressStore()
let content: Content
let progress: ProgressStore
let updater = Updater()
@Published var themeId: ThemeId
@ -19,6 +19,9 @@ final class AppModel: ObservableObject {
@Published var showChangelog = false
init() {
let content = Content.load()
self.content = content
progress = ProgressStore(content: content)
themeId = progress.themeId
mode = progress.mode
lang = progress.lang

View file

@ -67,6 +67,8 @@ final class Content {
let mluvnice: [J]
let books: [J]
let changelog: [J]
let progressKeys: [String: String]
let progressLegacy: [String: String]
init(_ root: [String: Any]) {
raw = J(root)
@ -131,6 +133,9 @@ final class Content {
mluvnice = raw.arr("mluvnice")
books = raw.arr("books")
changelog = raw.arr("changelog")
let progress = root["progress"] as? [String: Any]
progressKeys = progress?["keys"] as? [String: String] ?? [:]
progressLegacy = progress?["legacy"] as? [String: String] ?? [:]
}
func germanUnit(_ id: Int) -> J? { german.first { $0.int("id") == id } }

View file

@ -2,6 +2,26 @@ import Foundation
final class ProgressStore {
private let defaults = UserDefaults.standard
private let ids: [String: String]
init(content: Content) {
ids = content.progressKeys
migrate(content.progressLegacy)
}
/// Progress is stored under each lesson's id from content/, so lessons can be
/// reordered or renamed without losing it. `address` is the in-app position.
private func key(_ address: String) -> String { "done." + (ids[address] ?? address) }
private func done(_ address: String) -> Bool { defaults.bool(forKey: key(address)) }
private func mark(_ address: String) { defaults.set(true, forKey: key(address)) }
private func migrate(_ legacy: [String: String]) {
guard !defaults.bool(forKey: "progress_ids_v2") else { return }
for (old, id) in legacy where defaults.bool(forKey: old) {
defaults.set(true, forKey: "done." + id)
}
defaults.set(true, forKey: "progress_ids_v2")
}
var themeId: ThemeId {
get { ThemeId(rawValue: defaults.integer(forKey: "theme")) ?? .catppuccin }
@ -23,75 +43,75 @@ final class ProgressStore {
set { defaults.set(newValue, forKey: "seen_commit") }
}
func germanDone(_ unit: Int, _ ex: Int) -> Bool { defaults.bool(forKey: "g.\(unit).\(ex)") }
func markGerman(_ unit: Int, _ ex: Int) { defaults.set(true, forKey: "g.\(unit).\(ex)") }
func vocabDone(_ unit: Int) -> Bool { defaults.bool(forKey: "g.\(unit).vocab") }
func markVocab(_ unit: Int) { defaults.set(true, forKey: "g.\(unit).vocab") }
func germanDone(_ unit: Int, _ ex: Int) -> Bool { done("g.\(unit).\(ex)") }
func markGerman(_ unit: Int, _ ex: Int) { mark("g.\(unit).\(ex)") }
func vocabDone(_ unit: Int) -> Bool { done("g.\(unit).vocab") }
func markVocab(_ unit: Int) { mark("g.\(unit).vocab") }
func netDone(_ id: Int) -> Bool { defaults.bool(forKey: "net.\(id)") }
func markNet(_ id: Int) { defaults.set(true, forKey: "net.\(id)") }
func netDone(_ id: Int) -> Bool { done("net.\(id)") }
func markNet(_ id: Int) { mark("net.\(id)") }
func hwDone(_ id: Int) -> Bool { defaults.bool(forKey: "hw.\(id)") }
func markHw(_ id: Int) { defaults.set(true, forKey: "hw.\(id)") }
func hwDone(_ id: Int) -> Bool { done("hw.\(id)") }
func markHw(_ id: Int) { mark("hw.\(id)") }
func onDone(_ id: Int) -> Bool { defaults.bool(forKey: "on.\(id)") }
func markOn(_ id: Int) { defaults.set(true, forKey: "on.\(id)") }
func onDone(_ id: Int) -> Bool { done("on.\(id)") }
func markOn(_ id: Int) { mark("on.\(id)") }
func on2Done(_ id: Int) -> Bool { defaults.bool(forKey: "on2.\(id)") }
func markOn2(_ id: Int) { defaults.set(true, forKey: "on2.\(id)") }
func on2Done(_ id: Int) -> Bool { done("on2.\(id)") }
func markOn2(_ id: Int) { mark("on2.\(id)") }
func on3Done(_ id: Int) -> Bool { defaults.bool(forKey: "on3.\(id)") }
func markOn3(_ id: Int) { defaults.set(true, forKey: "on3.\(id)") }
func on3Done(_ id: Int) -> Bool { done("on3.\(id)") }
func markOn3(_ id: Int) { mark("on3.\(id)") }
func on4Done(_ id: Int) -> Bool { defaults.bool(forKey: "on4.\(id)") }
func markOn4(_ id: Int) { defaults.set(true, forKey: "on4.\(id)") }
func on4Done(_ id: Int) -> Bool { done("on4.\(id)") }
func markOn4(_ id: Int) { mark("on4.\(id)") }
func enDone(_ year: Int, _ id: Int) -> Bool { defaults.bool(forKey: "en.\(year).\(id)") }
func markEn(_ year: Int, _ id: Int) { defaults.set(true, forKey: "en.\(year).\(id)") }
func deDone(_ year: Int, _ id: Int) -> Bool { defaults.bool(forKey: "de.\(year).\(id)") }
func markDe(_ year: Int, _ id: Int) { defaults.set(true, forKey: "de.\(year).\(id)") }
func courseDone(_ course: String, _ id: Int) -> Bool { defaults.bool(forKey: "\(course).\(id)") }
func markCourse(_ course: String, _ id: Int) { defaults.set(true, forKey: "\(course).\(id)") }
func enDone(_ year: Int, _ id: Int) -> Bool { done("en.\(year).\(id)") }
func markEn(_ year: Int, _ id: Int) { mark("en.\(year).\(id)") }
func deDone(_ year: Int, _ id: Int) -> Bool { done("de.\(year).\(id)") }
func markDe(_ year: Int, _ id: Int) { mark("de.\(year).\(id)") }
func courseDone(_ course: String, _ id: Int) -> Bool { done("\(course).\(id)") }
func markCourse(_ course: String, _ id: Int) { mark("\(course).\(id)") }
func litDone(_ id: Int) -> Bool { defaults.bool(forKey: "lit.\(id)") }
func markLit(_ id: Int) { defaults.set(true, forKey: "lit.\(id)") }
func lit2Done(_ id: Int) -> Bool { defaults.bool(forKey: "lit2.\(id)") }
func markLit2(_ id: Int) { defaults.set(true, forKey: "lit2.\(id)") }
func lit3Done(_ id: Int) -> Bool { defaults.bool(forKey: "lit3.\(id)") }
func markLit3(_ id: Int) { defaults.set(true, forKey: "lit3.\(id)") }
func lit4Done(_ id: Int) -> Bool { defaults.bool(forKey: "lit4.\(id)") }
func markLit4(_ id: Int) { defaults.set(true, forKey: "lit4.\(id)") }
func litDone(_ id: Int) -> Bool { done("lit.\(id)") }
func markLit(_ id: Int) { mark("lit.\(id)") }
func lit2Done(_ id: Int) -> Bool { done("lit2.\(id)") }
func markLit2(_ id: Int) { mark("lit2.\(id)") }
func lit3Done(_ id: Int) -> Bool { done("lit3.\(id)") }
func markLit3(_ id: Int) { mark("lit3.\(id)") }
func lit4Done(_ id: Int) -> Bool { done("lit4.\(id)") }
func markLit4(_ id: Int) { mark("lit4.\(id)") }
func chemDone(_ id: Int) -> Bool { defaults.bool(forKey: "chem.\(id)") }
func markChem(_ id: Int) { defaults.set(true, forKey: "chem.\(id)") }
func bioDone(_ id: Int) -> Bool { defaults.bool(forKey: "bio.\(id)") }
func markBio(_ id: Int) { defaults.set(true, forKey: "bio.\(id)") }
func fyzDone(_ id: Int) -> Bool { defaults.bool(forKey: "fyz.\(id)") }
func markFyz(_ id: Int) { defaults.set(true, forKey: "fyz.\(id)") }
func fyz2Done(_ id: Int) -> Bool { defaults.bool(forKey: "fyz2.\(id)") }
func markFyz2(_ id: Int) { defaults.set(true, forKey: "fyz2.\(id)") }
func fyz3Done(_ id: Int) -> Bool { defaults.bool(forKey: "fyz3.\(id)") }
func markFyz3(_ id: Int) { defaults.set(true, forKey: "fyz3.\(id)") }
func fyz4Done(_ id: Int) -> Bool { defaults.bool(forKey: "fyz4.\(id)") }
func markFyz4(_ id: Int) { defaults.set(true, forKey: "fyz4.\(id)") }
func mat0Done(_ id: Int) -> Bool { defaults.bool(forKey: "mat0.\(id)") }
func markMat0(_ id: Int) { defaults.set(true, forKey: "mat0.\(id)") }
func matDone(_ id: Int) -> Bool { defaults.bool(forKey: "mat.\(id)") }
func markMat(_ id: Int) { defaults.set(true, forKey: "mat.\(id)") }
func mat2Done(_ id: Int) -> Bool { defaults.bool(forKey: "mat2.\(id)") }
func markMat2(_ id: Int) { defaults.set(true, forKey: "mat2.\(id)") }
func mat3Done(_ id: Int) -> Bool { defaults.bool(forKey: "mat3.\(id)") }
func markMat3(_ id: Int) { defaults.set(true, forKey: "mat3.\(id)") }
func mat4Done(_ id: Int) -> Bool { defaults.bool(forKey: "mat4.\(id)") }
func markMat4(_ id: Int) { defaults.set(true, forKey: "mat4.\(id)") }
func chemDone(_ id: Int) -> Bool { done("chem.\(id)") }
func markChem(_ id: Int) { mark("chem.\(id)") }
func bioDone(_ id: Int) -> Bool { done("bio.\(id)") }
func markBio(_ id: Int) { mark("bio.\(id)") }
func fyzDone(_ id: Int) -> Bool { done("fyz.\(id)") }
func markFyz(_ id: Int) { mark("fyz.\(id)") }
func fyz2Done(_ id: Int) -> Bool { done("fyz2.\(id)") }
func markFyz2(_ id: Int) { mark("fyz2.\(id)") }
func fyz3Done(_ id: Int) -> Bool { done("fyz3.\(id)") }
func markFyz3(_ id: Int) { mark("fyz3.\(id)") }
func fyz4Done(_ id: Int) -> Bool { done("fyz4.\(id)") }
func markFyz4(_ id: Int) { mark("fyz4.\(id)") }
func mat0Done(_ id: Int) -> Bool { done("mat0.\(id)") }
func markMat0(_ id: Int) { mark("mat0.\(id)") }
func matDone(_ id: Int) -> Bool { done("mat.\(id)") }
func markMat(_ id: Int) { mark("mat.\(id)") }
func mat2Done(_ id: Int) -> Bool { done("mat2.\(id)") }
func markMat2(_ id: Int) { mark("mat2.\(id)") }
func mat3Done(_ id: Int) -> Bool { done("mat3.\(id)") }
func markMat3(_ id: Int) { mark("mat3.\(id)") }
func mat4Done(_ id: Int) -> Bool { done("mat4.\(id)") }
func markMat4(_ id: Int) { mark("mat4.\(id)") }
func mluvDone(_ n: Int) -> Bool { defaults.bool(forKey: "mluv.\(n)") }
func markMluv(_ n: Int) { defaults.set(true, forKey: "mluv.\(n)") }
func mluvDone(_ n: Int) -> Bool { done("mluv.\(n)") }
func markMluv(_ n: Int) { mark("mluv.\(n)") }
func bookQuiz(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).quiz") }
func markBookQuiz(_ id: String) { defaults.set(true, forKey: "book.\(id).quiz") }
func bookPlot(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).plot") }
func markBookPlot(_ id: String) { defaults.set(true, forKey: "book.\(id).plot") }
func bookQuiz(_ id: String) -> Bool { done("book.\(id).quiz") }
func markBookQuiz(_ id: String) { mark("book.\(id).quiz") }
func bookPlot(_ id: String) -> Bool { done("book.\(id).plot") }
func markBookPlot(_ id: String) { mark("book.\(id).plot") }
}
struct ProgressSum {