Skip to content

Kits.kt

src/jvmMain/kotlin/afield/document/workspace/Kits.kt · 370 lines

package and imports — 44 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Workspace")

package afield.document.workspace

import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.data.graph.rules
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.clear
import afield.document.properties.compareTo
import afield.graphics.render.color
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.max
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.runtime.execution.go

import afield.audio.synth.*
import afield.data.collections.get
import afield.geometry.mesh.summary
import afield.graphics.interaction.groups
import afield.graphics.render.label
import afield.graphics.vulkan.buffers.add
import afield.media.image.channel
import afield.study.source.BoxStudies
import afield.study.source.SourceStudy
import afield.document.graph.Box
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.File

Kits package the shared parts of one kind of document so many documents can be installed from, and migrated to, the same source. A kit is what every document of one kind receives: the code of the boxes that play its roles (providers), the sources those boxes hold in other properties (a rule set, a recipe), and the asset files they read from the document's files/ folder. It is a directory with a kit.json beside those files; tools/field_kit.py makes one from a document, rehashes it, installs it in a document, migrates a document to a newer revision and publishes it as a Welcome starting point. public/docs/kits.md is the user's page.

{
  "schema": "field.kit.v1", "name": "rooms", "roleProperty": "kitRole",
  "launchOrder": ["rules", "plan"],
  "roles": {"rules": {"name": "Rules", "properties": {
      "code":       {"file": "roles/rules/code.kt",       "sha256": "…"},
      "roomRules":  {"file": "roles/rules/roomRules.json", "sha256": "…"},
      "rulesAudit": {"file": "roles/rules/roomRules.json", "sha256": "…", "frozen": true}}}},
  "assets": {"palette.json": "…"},
  "revision": "…"
}

A box plays a role when its KitManifest.roleProperty holds the role's key. Each property is installed from file in the kit, and sha256 is the hash of that file's bytes, which is also the hash of the property's text (SourceStudy.hash). An asset palette.json comes from the kit's files/palette.json and lands in the document's files/palette.json. The revision is the hash of everything else, so two kits with the same revision install the same bytes.

The document owns a copy. A document on a kit records the manifest it was last installed from in files/kit.json (its record), with the kit's directory. Nothing changes a document's copy behind its back: a newer kit is only a difference Kits.inspect reports, until a migration installs it. Kits.inspect compares three hashes for every property and asset: the record's, the document's, and the kit's. The document matching the kit is current; matching the record (and not the kit) means the kit changed it, which a migration installs; matching neither means the document's own edit, which a migration refuses to overwrite. A frozen property is a copy the document must not edit, such as the baseline an audit checks candidates against; a migration replaces it whenever it differs from the kit, and says so.

object Kits

Reads kit manifests and document records, and compares a document's role boxes and assets against them.

object Kits {

const val RECORD = "files/kit.json"

The record's path inside a document folder: files/kit.json, readable from a box as "kit.json".file.

const val DEFAULT_ROLE_PROPERTY = "kitRole"

The role property a kit uses unless its manifest names another. Field persists it.

    private val mapper = jacksonObjectMapper()

fun record(documentDir: File): KitManifest?

The record of the document in documentDir, or null when the document is not on a kit.

    fun record(documentDir: File): KitManifest? = File(documentDir, RECORD).takeIf { it.isFile }?.let(::read)

fun read(file: File): KitManifest

Read a manifest: a kit's kit.json, a kit directory, or a document's record.

    fun read(file: File): KitManifest {
        val manifest = if (file.isDirectory) File(file, "kit.json") else file
        require(manifest.isFile) { "No kit.json at ${file.path}" }
        val node = try { mapper.readTree(manifest) } catch (e: Exception) {
            throw IllegalArgumentException("${manifest.path} is not valid JSON: ${e.message}")
        }
        return KitManifest.from(node, if (file.isDirectory) file.absoluteFile else null)
    }

fun declare(kit: KitManifest)

Have Field save every property kit installs, as sidecar files named by the kit file's extension. A property Field does not know is neither autosaved nor written; inspecting a document or opening one that holds a record calls this.

    fun declare(kit: KitManifest) {
        Box.ioPersistentProperties.add(kit.roleProperty)
        kit.roles.values.flatMap { it.properties.entries }.forEach { (property, entry) ->
            if (property == "code" || Box.isNeverPersisted(property)) return@forEach
            Box.ioPersistentProperties.add(property)
            val extension = entry.file.substringAfterLast('.', "").lowercase().takeIf { it.matches(Regex("[a-z0-9]{1,12}")) } ?: "txt"
            Box.ioSeparateProperties.putIfAbsent(property, extension)
        }
    }

internal fun declareRecorded(documentDir: File)

declare the record of the document in documentDir, if it has one. Never throws.

    internal fun declareRecorded(documentDir: File) {
        runCatching { record(documentDir)?.let(::declare) }
    }

fun inspect(boxes: Collection<Box>, documentDir: File, kit: File? = null): KitInspection

What the document in documentDir, holding boxes, runs and how it differs from its kit. kit is the kit to compare against (a directory or its kit.json); null means the one the record names. A document with no record compares against kit alone, as before installing it.

body — 49 lines, from line 133
    fun inspect(boxes: Collection<Box>, documentDir: File, kit: File? = null): KitInspection {
        val recorded = record(documentDir)
        val kitDirectory = kit?.let { if (it.name == "kit.json" && it.isFile) it.parentFile else it }?.absoluteFile
            ?: recorded?.directory?.let(::File)
            ?: documentDir.takeIf { recorded != null && File(it, "kit.json").isFile }
        val current = kitDirectory?.takeIf { File(it, "kit.json").isFile }?.let(::read)
        val problems = mutableListOf<String>()
        if (recorded == null && current == null) {
            return KitInspection(null, null, null, kitDirectory?.path, false, emptyList(),
                listOf(if (kitDirectory != null) "No kit.json in ${kitDirectory.path}" else "This document is not on a kit"))
        }
        if (kitDirectory != null && current == null) problems += "The kit is not at ${kitDirectory.path}; " +
            "compared with the record only. Pass the kit's directory to compare with it."
        if (recorded != null && current != null && recorded.name != current.name) {
            problems += "The document is on kit '${recorded.name}', not '${current.name}'"
        }
        val manifest = current ?: recorded!!
        declare(manifest)
        recorded?.let(::declare)
        val roleProperty = manifest.roleProperty
        val live = boxes.filter { it.getPropertyRaw("_tombstone") != true }
        val byRole = live.mapNotNull { box -> (box.getPropertyRaw(roleProperty) as? String)?.let { it to box } }.groupBy({ it.first }, { it.second })
        val items = mutableListOf<KitItem>()
        val roles = (recorded?.roles?.keys.orEmpty() + current?.roles?.keys.orEmpty()).distinct()
        for (role in roles) {
            val claimed = byRole[role].orEmpty()
            if (claimed.size > 1) problems += "${claimed.size} boxes play role '$role' (" + claimed.joinToString { it.name.ifBlank { it.id } } +
                "); clear $roleProperty on all but one"
            val box = claimed.singleOrNull()
            val inRecord = recorded?.roles?.get(role)
            val inKit = current?.roles?.get(role)
            for (property in (inRecord?.properties?.keys.orEmpty() + inKit?.properties?.keys.orEmpty()).distinct()) {
                val text = box?.getPropertyRaw(property) as? String
                val frozen = (inKit ?: inRecord)?.properties?.get(property)?.frozen == true
                items += KitItem.compare(role, property, null, box?.id, box?.name?.ifBlank { null } ?: (inKit ?: inRecord)?.name,
                    inRecord?.properties?.get(property)?.sha256, text?.let { SourceStudy.hash(it) },
                    inKit?.properties?.get(property)?.sha256, frozen, kitKnown = current != null, inKit = inKit != null)
            }
        }
        val files = File(documentDir, "files")
        for (asset in (recorded?.assets?.keys.orEmpty() + current?.assets?.keys.orEmpty()).distinct()) {
            val file = File(files, asset)
            items += KitItem.compare(null, null, asset, null, null, recorded?.assets?.get(asset),
                file.takeIf { it.isFile }?.let(::fileHash),
                current?.assets?.get(asset), frozen = false, kitKnown = current != null, inKit = current?.assets?.containsKey(asset) == true)
        }
        return KitInspection(manifest.name, recorded?.revision, current?.revision, kitDirectory?.path, current != null, items, problems,
            roleProperty, current?.launchOrder ?: recorded!!.launchOrder)
    }

fun fileHash(file: File): String

SHA-256 of a file's bytes, the hash a kit records for it.

    fun fileHash(file: File): String = java.security.MessageDigest.getInstance("SHA-256").digest(file.readBytes())
        .joinToString("") { "%02x".format(it) }
}

data class KitProperty(val file: String, val sha256: String, val frozen: Boolean = false)

One property a kit installs on a role's box: the kit file it comes from, that file's hash, and whether it is a frozen copy.

data class KitRole(val name: String, val properties: Map<String, KitProperty>)

A role: the box name a new document gives it, and the properties the kit installs on that box.

data class KitManifest(val name: String, val roleProperty: String, val launchOrder: List<String>,

A parsed kit.json or document record. directory is where the kit lives, when known.

data class KitManifest(val name: String, val roleProperty: String, val launchOrder: List<String>,
    val roles: Map<String, KitRole>, val assets: Map<String, String>, val revision: String, val directory: String?) {

internal companion object

fun from(node: JsonNode, directory: File?): KitManifest

        fun from(node: JsonNode, directory: File?): KitManifest {
            fun text(n: JsonNode, key: String, where: String): String = n.path(key).takeIf { it.isTextual }?.asText()
                ?: throw IllegalArgumentException("$where needs \"$key\"")
            val schema = node.path("schema").asText("")
            require(schema == "field.kit.v1") { "Not a kit manifest (schema '$schema', expected field.kit.v1)" }
            val roles = node.path("roles").properties().asSequence().associate { (key, role) ->
                key to KitRole(role.path("name").asText(key), role.path("properties").properties().asSequence().associate { (property, entry) ->
                    property to KitProperty(text(entry, "file", "roles.$key.$property"), text(entry, "sha256", "roles.$key.$property"),
                        entry.path("frozen").asBoolean(false))
                })
            }
            val order = node.path("launchOrder").map { it.asText() }
            order.firstOrNull { it !in roles }?.let { throw IllegalArgumentException("launchOrder names '$it', which is not a role") }
            return KitManifest(text(node, "name", "kit.json"), node.path("roleProperty").asText(Kits.DEFAULT_ROLE_PROPERTY),
                order, roles, node.path("assets").properties().asSequence().associate { it.key to it.value.asText() },
                text(node, "revision", "kit.json"), directory?.path ?: node.path("directory").takeIf { it.isTextual }?.asText())
        }
    }
}

enum class KitItemState

How a document's copy of one kit property or asset stands against its record and its kit.

enum class KitItemState {

The document holds what the kit holds.

    CURRENT,

The kit changed it since the record; the document still holds the recorded copy. A migration installs the kit's.

    KIT_CHANGED,

The document holds neither the recorded copy nor the kit's: its own edit. A migration refuses to overwrite it.

    DOCUMENT_EDITED,

A frozen copy that differs from the kit's. A migration replaces it, whatever it holds.

    FROZEN_STALE,

In the record but no longer in the kit. A migration leaves the document's copy alone and stops tracking it.

    REMOVED_FROM_KIT,
}

data class KitItem(val role: String?, val property: String?, val asset: String?, val box: String?, val boxName: String?,

One property (role and property) or asset, with the three hashes: recorded, document and kit (null when absent, or when the kit is not known).

data class KitItem(val role: String?, val property: String?, val asset: String?, val box: String?, val boxName: String?,
    val recorded: String?, val document: String?, val kit: String?, val frozen: Boolean, val state: KitItemState) {

val label: String get() = asset ?: "$role · $property"

rules · roomRules, or the asset's path.

val display: String get() = asset?.let { "files/$it" } ?: "${boxName ?: role} · $property"

How a person names it: Rules · roomRules (the box's name), or files/palette.txt.

internal companion object

fun compare(role: String?, property: String?, asset: String?, box: String?, boxName: String?, recorded: String?,

        fun compare(role: String?, property: String?, asset: String?, box: String?, boxName: String?, recorded: String?,
            document: String?, kit: String?, frozen: Boolean, kitKnown: Boolean, inKit: Boolean): KitItem {
            // Without the kit, the record is the only reference; a removed role or asset is the kit's decision.
            val reference = if (kitKnown) kit else recorded
            val state = when {
                kitKnown && !inKit -> KitItemState.REMOVED_FROM_KIT
                document == reference -> KitItemState.CURRENT
                frozen -> KitItemState.FROZEN_STALE
                kitKnown && document == recorded -> KitItemState.KIT_CHANGED
                else -> KitItemState.DOCUMENT_EDITED
            }
            return KitItem(role, property, asset, box, boxName, recorded, document, if (kitKnown) kit else null, frozen, state)
        }
    }
}

data class KitInspection(val kit: String?, val recordedRevision: String?, val kitRevision: String?, val kitDirectory: String?,

What Kits.inspect found: the kit's name, the recordedRevision the document runs, the kitRevision its kit is at now (null when the kit was not found), every items entry, and problems that stop a migration whatever the items say.

data class KitInspection(val kit: String?, val recordedRevision: String?, val kitRevision: String?, val kitDirectory: String?,
    val kitFound: Boolean, val items: List<KitItem>, val problems: List<String>,
    val roleProperty: String = Kits.DEFAULT_ROLE_PROPERTY, val launchOrder: List<String> = emptyList(),
    /** The document's kit package, when it was created from one; [Box.showKit] fills it in. */
    val packageInstance: KitInstanceInspection? = null) {

fun inState(state: KitItemState): List<KitItem> = items.filter { it.state == state }

Items in state.

val migrationRefusals: List<String> get()

Whether a migration to the kit would go ahead: something to do, and no document edit or problem in the way.

    val migrationRefusals: List<String> get() = problems + inState(KitItemState.DOCUMENT_EDITED).map { item ->
        val held = if (item.kit == null || item.kit == item.recorded) "the record and the kit hold ${short(item.recorded)}"
            else "the record holds ${short(item.recorded)} and the kit ${short(item.kit)}"
        if (item.document == null) "${item.display} was removed in this document ($held)"
        else "${item.display} was edited in this document (it holds ${short(item.document)}; $held)"
    }

fun summary(): String

One paragraph, for the agent and the card.

    fun summary(): String {
        if (kit == null) return problems.joinToString(". ").ifBlank { "This document is not on a kit" } + "."
        val runs = recordedRevision?.let { "This document runs kit '$kit' at revision ${short(it)}" } ?: "This document is not on kit '$kit' yet"
        val at = when {
            !kitFound -> ""
            kitRevision == recordedRevision -> "; the kit is at the same revision"
            else -> "; the kit is at revision ${short(kitRevision)}"
        }
        val groups = KitItemState.entries.filter { it != KitItemState.CURRENT }.mapNotNull { state ->
            inState(state).takeIf { it.isNotEmpty() }?.let { found -> "${STATE_WORDS.getValue(state)}: " + found.joinToString { it.display } + "." }
        }
        val differs = if (groups.isEmpty()) ". Nothing differs from the kit." else ". " + groups.joinToString(" ")
        return runs + at + differs + problems.joinToString("") { " $it." }
    }

internal companion object

fun short(hash: String?): String = hash?.take(8) ?: "absent"

val STATE_WORDS

        val STATE_WORDS = mapOf(
            KitItemState.KIT_CHANGED to "Changed in the kit, which a migration installs",
            KitItemState.DOCUMENT_EDITED to "Edited in this document, which a migration refuses to overwrite",
            KitItemState.FROZEN_STALE to "Frozen copies that differ from the kit, which a migration replaces",
            KitItemState.REMOVED_FROM_KIT to "No longer in the kit, which a migration leaves alone",
        )
    }
}

fun Box.showKit(kit: File? = null, channel: String = "kit"): KitInspection

Show which kit this document runs, and every property and asset that differs from it, in a box output (channel, by default kit). kit is a kit directory to compare against; by default, the one the document's record names.

A document created from a kit package (one with a .field-kit-instance.json) also gets the package's line on the card: the package, the revision this document was made from, the installed revision, and what an update would change. KitInspection.packageInstance carries it.

fun Box.showKit(kit: File? = null, channel: String = "kit"): KitInspection {
    val document = File(inside.filename)
    val packageInstance = runCatching { KitInstances.record(document)?.let { KitInstances.inspect(document) } }.getOrNull()
    val inspection = Kits.inspect(inside.boxes.values, document, kit).copy(packageInstance = packageInstance)
    BoxStudies.show(this, channel, kitCardHtml(inspection))
    return inspection
}

internal fun packageSummary(p: KitInstanceInspection): String

One line for a document's kit package: revision, installed revision, and what an update would do.

internal fun packageSummary(p: KitInstanceInspection): String {
    val made = "Made from package '${p.record.kit}' at revision ${KitInspection.short(p.record.revision)}"
    if (p.availableRevision.isEmpty()) return "$made; " + p.conflicts.joinToString("; ").ifBlank { "it is not installed" } + "."
    val installed = if (p.availableRevision == p.record.revision) "; that revision is installed"
        else "; revision ${KitInspection.short(p.availableRevision)} is installed"
    val changes = p.changes.groupBy { it.state }.entries.joinToString(", ") { (state, list) -> "${list.size} ${state.replace('-', ' ')}" }
    val update = when {
        p.conflicts.isNotEmpty() -> ". An update would conflict on ${p.conflicts.joinToString()}"
        changes.isNotEmpty() -> ". Files differ: $changes"
        else -> ". No file differs from it"
    }
    return made + installed + update + "."
}

internal fun kitCardHtml(inspection: KitInspection): String

internal fun kitCardHtml(inspection: KitInspection): String {
    val prefix = "kit-card"
    fun row(item: KitItem): String {
        val (word, colour) = when (item.state) {
            KitItemState.CURRENT -> "current" to "#999"
            KitItemState.KIT_CHANGED -> "changed in the kit" to "#55d4f2"
            KitItemState.DOCUMENT_EDITED -> if (item.document == null) "removed here" to "#ff6969" else "edited here" to "#ff6969"
            KitItemState.FROZEN_STALE -> "frozen copy out of date" to "#f5b748"
            KitItemState.REMOVED_FROM_KIT -> "no longer in the kit" to "#999"
        }
        return "<tr><td>${kitEscape(item.display)}</td><td style=\"color:$colour\">$word</td></tr>"
    }
    val title = inspection.kit?.let { "Kit $it" } ?: "No kit"
    val revision = inspection.recordedRevision?.let { " · revision ${KitInspection.short(it)}" }.orEmpty()
    val rows = inspection.items.sortedWith(compareBy({ it.asset != null }, { inspection.launchOrder.indexOf(it.role) }, { it.label }))
        .joinToString("", transform = ::row)
    return """
<style>
.$prefix{background:#000;color:#ddd;padding:16px;border-radius:12px;font:14px/1.45 Iosevka,monospace;max-width:640px;box-sizing:border-box}
.$prefix .kit-title{display:flex;gap:12px;user-select:none}.$prefix .kit-title span{color:#999}
.$prefix p{margin:8px 0 0;user-select:text;white-space:pre-wrap;overflow-wrap:anywhere}
.$prefix table{border-collapse:collapse;margin-top:12px;width:100%}.$prefix td{padding:3px 12px 3px 0;vertical-align:top}.$prefix td+td{text-align:right;padding-right:0}
</style><div class="$prefix" data-kit="${kitEscape(inspection.kit.orEmpty())}" data-afield-intrinsic-width="640">
<div class="kit-title">$title<span>${kitEscape(revision)}</span></div><p>${kitEscape(inspection.summary())}</p>${inspection.packageInstance?.let { "<p class=\"kit-package\">${kitEscape(packageSummary(it))}</p>" }.orEmpty()}
<table>$rows</table></div>
""".trimIndent()
}

private fun kitEscape(text: String): String = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")