Skip to content

PresentationSlides.kt

src/jvmMain/kotlin/afield/ui/slides/PresentationSlides.kt · 163 lines

package afield.ui.slides

import afield.app.protocol.AudienceContent
import afield.app.server.ReadOnlyDocuments
import afield.app.server.documents
import afield.document.workspace.file
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest

The on-disk format for a talk's editable slides, and the two slides(...) builders that read a deck into a slideshow.

A deck is a files/<name>.slides.json inside the presentation document: a schema string, a stylesheet path, and a list of slides carrying Markdown for the audience, presenter notes, an optional section path and an optional list of reveals. validated() bounds every field of it (at most 1000 uniquely identified slides, 100 000 characters of Markdown each, 100 reveals), and both reading and writing run it.

PresentationSlideFiles is the file side, shared by Kotlin box code and by AI4's slide tools in PresentationTools. A write names the revision it read, so a deck edited underneath a caller is refused rather than overwritten, and every path is checked to stay inside the document's files/ directory.

A deck carries Markdown and no code; a live action stays an explicit Kotlin guide step.

data class PresentationSlides

Editable content for a talk. Live actions remain explicit Kotlin guide steps.

@Serializable
data class PresentationSlides(
    val schema: String = "field.presentation.slides.v1",
    val stylesheet: String = "presentation.css",
    val slides: List<PresentationSlide> = emptyList()
) {

fun validated(): PresentationSlides

    fun validated(): PresentationSlides = apply {
        require(schema == "field.presentation.slides.v1") { "Unknown slide file schema" }
        require(slides.size <= 1000 && slides.map { it.id }.distinct().size == slides.size) { "Use at most 1000 uniquely identified slides" }
        require(stylesheet.isEmpty() || (stylesheet.length <= 512 && !stylesheet.contains('"') && !stylesheet.contains('<') && !stylesheet.contains('>'))) { "Invalid stylesheet path" }
        slides.forEach { slide ->
            require(slide.id.matches(Regex("[A-Za-z0-9][A-Za-z0-9_-]{0,127}"))) { "Use a short stable slide ID: ${slide.id}" }
            require(slide.title.isNotBlank() && slide.title.length <= 4096) { "A slide needs a title" }
            require(slide.markdown.length <= 100_000 && slide.notes.length <= 32_000) { "Slide text exceeds the limit" }
            require(slide.section.size <= 16 && slide.section.all { it.isNotBlank() && it.length <= 4096 }) { "Invalid slide section" }
            require(slide.cues.size <= 100 && slide.cues.all { it.title.isNotBlank() && it.title.length <= 4096 && it.markdown.length <= 100_000 && it.fadeMs in 0..10_000 && it.previousOpacity.isFinite() && it.previousOpacity in 0.0..1.0 }) { "Invalid slide cues" }
        }
    }
}

data class PresentationSlide(val id: String, val title: String, val markdown: String,

@Serializable
data class PresentationSlide(val id: String, val title: String, val markdown: String,
    val notes: String = "", val section: List<String> = emptyList(), val cues: List<PresentationReveal> = emptyList())

data class PresentationReveal(val title: String, val markdown: String,

A content-only reveal replaces or layers the slide body and never executes code.

@Serializable
data class PresentationReveal(val title: String, val markdown: String,
    val retainPrevious: Boolean = false, val previousOpacity: Double = 0.25, val fadeMs: Int = 0)

class PresentationSlideFiles(private val documentDirectory: File)

Document-local files shared by the Kotlin builder and AI4's revision-checked slide tools.

class PresentationSlideFiles(private val documentDirectory: File) {
    private val root = documentDirectory.canonicalFile
    private val json = Json { prettyPrint = true; encodeDefaults = true }
    private fun target(path: String): File {
        require(path.startsWith("files/") && path.endsWith(".slides.json") && !File(path).isAbsolute) { "Use files/<name>.slides.json inside this document" }
        val raw = File(root, path).toPath().normalize()
        require(raw.startsWith(File(root, "files").toPath())) { "Slide files must stay inside files/" }
        var probe = raw
        while (probe != root.toPath()) {
            require(!Files.isSymbolicLink(probe)) { "Slide paths cannot contain symbolic links" }
            probe = requireNotNull(probe.parent)
        }
        val file = File(root, path).canonicalFile
        require(file.toPath().startsWith(File(root, "files").canonicalFile.toPath()) && file.toPath().startsWith(root.toPath())) { "Slide files must stay inside this document" }
        return file
    }

data class Snapshot(val revision: String, val deck: PresentationSlides)

fun read(path: String): Snapshot = readFile(target(path))

companion object

    companion object {
        private val writeLock = Any()
        private val decoder = Json
        private fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }

internal fun readFile(file: File): Snapshot

        internal fun readFile(file: File): Snapshot {
            require(file.isFile && file.length() <= 2_000_000) { "Slide file is missing or exceeds 2 MB: ${file.name}" }
            val bytes = file.readBytes()
            require(bytes.size <= 2_000_000) { "Slide file exceeds 2 MB" }
            return Snapshot(hash(bytes), decoder.decodeFromString<PresentationSlides>(bytes.toString(Charsets.UTF_8)).validated())
        }
    }

fun write(path: String, revision: String, deck: PresentationSlides): Snapshot

Empty revision creates a new file; every replacement must name the exact revision just read.

    fun write(path: String, revision: String, deck: PresentationSlides): Snapshot = synchronized(writeLock) {
        if (ReadOnlyDocuments.blocksWritesTo(documentDirectory.path) || documents.values.any {
                it.readOnly && runCatching { File(it.filename).canonicalFile == root }.getOrDefault(false)
            }) {
            ReadOnlyDocuments.refuseWrite("presentation-slide", documentDirectory.path)
            error("This presentation document is read-only")
        }
        deck.validated()
        val file = target(path)
        fun matches() = if (file.exists()) revision.isNotEmpty() && readFile(file).revision == revision else revision.isEmpty()
        check(matches()) { "Slide file changed. Read again and reconcile; nothing was overwritten." }
        val bytes = json.encodeToString(PresentationSlides.serializer(), deck).toByteArray(Charsets.UTF_8)
        require(bytes.size <= 2_000_000) { "Slide file exceeds 2 MB" }
        file.parentFile.mkdirs()
        val tmp = Files.createTempFile(file.parentFile.toPath(), ".slides-", ".tmp")
        try {
            Files.write(tmp, bytes)
            check(matches()) { "Slide file changed. Read again and reconcile; nothing was overwritten." }
            if (file.exists()) runCatching { Files.setPosixFilePermissions(tmp, Files.getPosixFilePermissions(file.toPath())) }
            Files.move(tmp, file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
        } finally { Files.deleteIfExists(tmp) }
        readFile(file)
    }
}

fun SlideShowBuilder.slides(path: String) = slides(path.file)

Insert editable Markdown slides at this point in the talk. Refresh reloads existing slide bodies from disk.

fun SlideShowBuilder.slides(file: File)

Load a specific slide file; relative media still resolves in the presentation document's files directory.

fun SlideShowBuilder.slides(file: File) {
    val deck = PresentationSlideFiles.readFile(file).deck
    fun body(markdown: String, css: String): AudienceContent {
        val style = if (css.isEmpty()) "" else "<link rel=\"stylesheet\" data-field-presentation-style href=\"${css.replace("&", "&amp;").replace("\"", "&quot;")}\">"
        return AudienceContent(html = style + SlideMarkdown.render(markdown))
    }
    fun view(current: PresentationSlides, value: PresentationSlide, cueIndex: Int): AudienceContent {
        var content = body(value.markdown, current.stylesheet)
        value.cues.take(cueIndex).forEach { reveal ->
            val html = body(reveal.markdown, current.stylesheet)
            val next = if (reveal.fadeMs == 0) html else html.copy(html = """<div class="field-fade-in" style="--field-fade-duration:${reveal.fadeMs}ms">${html.html}</div>""")
            content = if (reveal.retainPrevious) layerAudience(content, next, reveal.previousOpacity) else next
        }
        return content
    }
    deck.slides.forEach { slide ->
        fun SlideShowBuilder.add(depth: Int) {
            if (depth < slide.section.size) section(slide.section[depth]) { add(depth + 1) }
            else step(slide.title, id = slide.id) {
                markdownNote(slide.notes)
                audienceProvider = {
                    val current = PresentationSlideFiles.readFile(file).deck
                    val value = current.slides.singleOrNull { it.id == slide.id } ?: error("Slide '${slide.id}' was removed; rebuild the Presentation box")
                    body(value.markdown, current.stylesheet)
                }
                slide.cues.forEachIndexed { index, reveal -> cue(reveal.title) {
                    audienceProvider = {
                        val current = PresentationSlideFiles.readFile(file).deck
                        val value = current.slides.singleOrNull { it.id == slide.id } ?: error("Slide was removed; rebuild the Presentation box")
                        check(index in value.cues.indices) { "Cue was removed; rebuild the Presentation box" }
                        view(current, value, index + 1)
                    }
                } }
            }
        }
        add(0)
    }
}