Arcology Engine

Capture Core — Org Heading Builder & Template Expander

Contents

The capture core provides the pure-function building blocks that every capture flow in the app depends on. CaptureService constructs org-mode heading strings with properties drawers (tags, aliases, refs, geo-coordinates, flashcard metadata). TemplateExpander implements org-capture-style template patterns with %-escapes for timestamps, clipboard, location, and user prompts.

The files live in src/commonMain with no platform dependencies, making them testable from JVM tests without Robolectric.

Design: Pure functions over AST manipulation

Both CaptureService and TemplateExpander are Kotlin object singletons with pure functions. This was a deliberate choice: the capture output is a flat org string, not an AST. Building an org-mode AST (via OrgLexer / parseWithDetails) would require a parser dependency, impose parse overhead on every capture, and force callers to know the AST structure for what is fundamentally a template-fill operation. String-building with StringBuilder is simpler, faster, and sufficient for the structured-but-linear format of org headings.

The tradeoff is that these functions don't validate the output — they produce syntactically correct headings for normal input, but can produce malformed org if given pathological inputs (e.g., empty titles with tags, or newlines in property values). In practice this hasn't been an issue because the callers (OrgDocumentEditorViewModel) render the text field and let the user see the result before saving.

CaptureService — building org headings and IDs

CaptureService provides four capabilities:

  • buildCaptureEntry() — construct a complete org heading string: = TODO Title :tags:=, optional :PROPERTIES: drawer (ID, ROAM_ALIASES, ROAM_REFS, GEO_COORDS, FC_TYPE, FC_CLOZE_TYPE, ARCOLOGY_ publishing keys), and body text.

  • buildDailyFileContent() — construct file-level properties drawer + #+title: for a new daily file.

  • dailyFileName() — format a LocalDate as YYYY-MM-DD.org.

  • generateId() — generate a datetime-based UUID string (20260127T111338.007294) from LocalDateTime + microsecond counter.

Design: Publishing metadata as a single value object

The Arcology web server routes pages off ARCOLOGY_KEY, with optional ARCOLOGY_EXPIRE (inactive org timestamp), ARCOLOGY_ALLOW_CRAWL, and ARCOLOGY_PAGE_TEMPLATE properties. These travel together — a key with no template or expiry still means "publish this" — so they are bundled into a PublishMetadata value rather than four loose parameters. null fields are omitted from the drawer entirely; allowCrawl is only emitted when the user has made a choice (true emits t, false emits nil); a blank key emits no properties at all, since an empty key would produce a bogus route.

CaptureService — the shared domain object

kotlin#+name: capture-service:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/capture/CaptureService.kt
package computer.whatthefuck.arcology.capture

import computer.whatthefuck.arcology.domain.GeoCoordinate
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime

/**
 * Publishing metadata for the Arcology web server.
 * When non-null [key], the node is published at SITE/path from ARCOLOGY_KEY;
 * the remaining fields map to ARCOLOGY_EXPIRE, ARCOLOGY_ALLOW_CRAWL, and
 * ARCOLOGY_PAGE_TEMPLATE properties and are omitted from the drawer when null.
 */
data class PublishMetadata(
    val key: String,
    val expire: String? = null,
    val allowCrawl: Boolean? = null,
    val pageTemplate: String? = null
)

/**
 * Pure functions for generating org-mode capture content.
 * No Android dependencies — usable from shared/JVM tests.
 */
object CaptureService {

    /**
     * Build an org-mode heading entry for capture.
     *
     * @param body The body text of the entry
     * @param title Optional title; defaults to HH:MM timestamp if null
     * @param tags Optional list of tags (rendered as :tag1:tag2:)
     * @param aliases Optional list of ROAM_ALIASES
     * @param todoState Optional TODO keyword (TODO, DOING, DONE)
     * @param refs Optional list of ROAM_REFS (URLs or other references)
     * @param id Pre-generated UUID string for the heading ID property
     * @param time Time used for default title generation
     * @param geoCoords Optional location coordinates to add as GEO_COORDS property
     * @param flashcardType Optional flashcard type — when non-null, injects :fc: tag and FC_TYPE property
     * @param clozeType Optional cloze subtype — when non-null, emits FC_CLOZE_TYPE property
     * @param publish Optional Arcology publishing metadata (ARCOLOGY_* properties)
     * @return A complete org heading string with properties drawer
     */
    fun buildCaptureEntry(
        body: String,
        title: String? = null,
        tags: List<String> = emptyList(),
        aliases: List<String> = emptyList(),
        refs: List<String> = emptyList(),
        todoState: String? = null,
        id: String? = null,
        time: LocalTime,
        geoCoords: GeoCoordinate? = null,
        flashcardType: computer.whatthefuck.arcology.domain.FlashcardType? = null,
        clozeType: computer.whatthefuck.arcology.domain.ClozeType? = null,
        publish: PublishMetadata? = null
    ): String {
        val effectiveTitle = if (title.isNullOrBlank()) {
            "${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}"
        } else {
            title
        }

        val todoPrefix = if (todoState != null) "$todoState " else ""
        val effectiveTags = if (flashcardType != null) {
            (tags + "fc").distinct()
        } else tags
        val tagSuffix = if (effectiveTags.isNotEmpty()) " :${effectiveTags.joinToString(":")}:" else ""

        val sb = StringBuilder()
        sb.appendLine("* $todoPrefix$effectiveTitle$tagSuffix")

        // Only include properties drawer if there are properties to set
        val hasProperties = id != null || aliases.isNotEmpty() || refs.isNotEmpty() || geoCoords != null
                || flashcardType != null || publish != null
        if (hasProperties) {
            sb.appendLine(":PROPERTIES:")
            if (id != null) {
                sb.appendLine(":ID:       $id")
            }
            if (aliases.isNotEmpty()) {
                sb.appendLine(":ROAM_ALIASES: ${aliases.joinToString(" ") { "\"$it\"" }}")
            }
            if (refs.isNotEmpty()) {
                sb.appendLine(":ROAM_REFS: ${refs.joinToString(" ") { "\"$it\"" }}")
            }
            if (geoCoords != null) {
                sb.appendLine(":GEO_COORDS: ${geoCoords.toPropertyValue()}")
            }
            if (flashcardType != null) {
                sb.appendLine(":FC_TYPE:       ${flashcardType.name.lowercase()}")
            }
            if (clozeType != null) {
                sb.appendLine(":FC_CLOZE_TYPE: ${clozeType.name.lowercase()}")
            }
            if (publish != null && publish.key.isNotBlank()) {
                sb.appendLine(":ARCOLOGY_KEY: ${publish.key}")
                publish.expire?.let { sb.appendLine(":ARCOLOGY_EXPIRE: $it") }
                publish.allowCrawl?.let { crawl ->
                    sb.appendLine(":ARCOLOGY_ALLOW_CRAWL: ${if (crawl) "t" else "nil"}")
                }
                publish.pageTemplate?.let { sb.appendLine(":ARCOLOGY_PAGE_TEMPLATE: $it") }
            }
            sb.appendLine(":END:")
        }
        if (body.isNotEmpty()) {
            sb.appendLine(body)
        }
        return sb.toString()
    }

    /**
     * Build file-level content for a new daily org file.
     *
     * @param date The date for the daily file
     * @param fileId UUID for the file-level node ID
     * @return File header with title and properties
     */
    fun buildDailyFileContent(date: LocalDate, fileId: String): String {
        val sb = StringBuilder()
        sb.appendLine(":PROPERTIES:")
        sb.appendLine(":ID:       $fileId")
        sb.appendLine(":END:")
        sb.appendLine("#+title: $date")
        sb.appendLine()
        return sb.toString()
    }

    /**
     * Generate the filename for a daily file.
     */
    fun dailyFileName(date: LocalDate): String {
        return "$date.org"
    }

    private val URL_PATTERN = Regex("^https?://\\S+$")

    /**
     * Determine if a string looks like a URL.
     */
    fun isUrl(text: String): Boolean = URL_PATTERN.matches(text.trim())

    /**
     * Cutoff length for shared text: text shorter than this becomes the title,
     * longer text becomes the body.
     */
    const val SHARE_TITLE_MAX_LENGTH = 120

    fun generateId(dateTime: LocalDateTime, microseconds: Int = 0): String {
        val d = dateTime.date
        val t = dateTime.time
        val datePart = "${d.year}" +
            "${d.monthNumber.toString().padStart(2, '0')}" +
            "${d.dayOfMonth.toString().padStart(2, '0')}"
        val timePart = "${t.hour.toString().padStart(2, '0')}" +
            "${t.minute.toString().padStart(2, '0')}" +
            "${t.second.toString().padStart(2, '0')}"
        val microPart = microseconds.toString().padStart(6, '0')
        return "${datePart}T${timePart}.${microPart}"
    }

    /**
     * Generate an inactive org-mode timestamp like [2026-01-27 Tue 11:07]
     */
    fun inactiveTimestamp(dateTime: LocalDateTime): String {
        val d = dateTime.date
        val t = dateTime.time
        val dayAbbrev = when (d.dayOfWeek) {
            DayOfWeek.MONDAY -> "Mon"
            DayOfWeek.TUESDAY -> "Tue"
            DayOfWeek.WEDNESDAY -> "Wed"
            DayOfWeek.THURSDAY -> "Thu"
            DayOfWeek.FRIDAY -> "Fri"
            DayOfWeek.SATURDAY -> "Sat"
            DayOfWeek.SUNDAY -> "Sun"
        }
        val datePart = "${d.year}-${d.monthNumber.toString().padStart(2, '0')}-${d.dayOfMonth.toString().padStart(2, '0')}"
        val timePart = "${t.hour.toString().padStart(2, '0')}:${t.minute.toString().padStart(2, '0')}"
        return "[$datePart $dayAbbrev $timePart] "
    }
}

TemplateExpander — org-capture-style %-escape expansion

TemplateExpander implements the core of org-capture's template syntax: replace %-escaped patterns with computed values. Supported escapes:

  • %t / %T — inactive/active date-only timestamp

  • %u / %U — inactive/active timestamp with time

  • %d — date-only (2026-02-02)

  • %c — clipboard contents (via callback)

  • %l — location string (via callback)

  • %page-title — page title from share intent (via callback)

  • %^{Label} — user prompt placeholder (collected in PromptRequest list)

  • %% — literal percent sign

TemplateExpander — pure template expansion engine

kotlin#+name: template-expander:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/capture/TemplateExpander.kt
package computer.whatthefuck.arcology.capture

import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDateTime

/**
 * Result of template expansion.
 * Contains the expanded text and any prompts that need user input.
 */
data class ExpansionResult(
    val text: String,
    val prompts: List<PromptRequest> = emptyList()
)

/**
 * A prompt that needs user input during template expansion.
 */
data class PromptRequest(
    val label: String,
    val placeholder: String  // The original %^{Label} string to replace
)

/**
 * Pure functions for expanding template patterns with %-escapes.
 *
 * Supported escapes:
 * - %t: Inactive timestamp [2026-02-02 Sun]
 * - %T: Active timestamp <2026-02-02 Sun>
 * - %u: Inactive timestamp with time [2026-02-02 Sun 15:30]
 * - %U: Active timestamp with time <2026-02-02 Sun 15:30>
 * - %d: Date only (2026-02-02)
 * - %c: Clipboard contents (via callback)
 * - %l: Location (via callback)
 * - %page-title: Page title from share intent (via callback)
 * - %^{Label}: Prompt user for input (returns in prompts list)
 * - %%: Literal %
 */
object TemplateExpander {

    private val PROMPT_PATTERN = Regex("""%\^\{([^}]+)\}""")

    /**
     * Expand a template pattern, returning expanded text and any prompts needed.
     *
     * @param pattern The template pattern with %-escapes
     * @param dateTime The current date/time for timestamp escapes
     * @param clipboardProvider Callback to get clipboard contents for %c
     * @param locationProvider Callback to get location string for %l
     * @param pageTitleProvider Callback to get page title from share intent for %page-title
     * @return ExpansionResult with expanded text and list of prompts
     */
    fun expand(
        pattern: String,
        dateTime: LocalDateTime,
        clipboardProvider: () -> String? = { null },
        locationProvider: () -> String? = { null },
        pageTitleProvider: () -> String? = { null }
    ): ExpansionResult {
        if (pattern.isEmpty()) return ExpansionResult("")

        // First, find all prompts
        val prompts = mutableListOf<PromptRequest>()
        PROMPT_PATTERN.findAll(pattern).forEach { match ->
            val label = match.groupValues[1]
            prompts.add(PromptRequest(label, match.value))
        }

        // Replace all escapes except prompts (those are handled separately)
        var result = pattern

        // Escape %% first (replace with placeholder, restore at end)
        val PERCENT_PLACEHOLDER = "\u0000PERCENT\u0000"
        result = result.replace("%%", PERCENT_PLACEHOLDER)

        // Timestamps
        result = result.replace("%u", inactiveTimestampWithTime(dateTime))
        result = result.replace("%U", activeTimestampWithTime(dateTime))
        result = result.replace("%t", inactiveTimestamp(dateTime))
        result = result.replace("%T", activeTimestamp(dateTime))
        result = result.replace("%d", dateOnly(dateTime))

        // Clipboard
        result = result.replace("%c", clipboardProvider() ?: "")

        // Location
        result = result.replace("%l", locationProvider() ?: "")

        // Page title (from share intent)
        result = result.replace("%page-title", pageTitleProvider() ?: "")

        // Restore literal %
        result = result.replace(PERCENT_PLACEHOLDER, "%")

        return ExpansionResult(result, prompts)
    }

    /**
     * Apply prompt responses to a partially expanded pattern.
     *
     * @param pattern The pattern (may contain %^{...} placeholders)
     * @param responses Map of label -> user response
     * @return The pattern with prompts replaced by responses
     */
    fun applyPromptResponses(pattern: String, responses: Map<String, String>): String {
        var result = pattern
        responses.forEach { (label, response) ->
            result = result.replace("%^{$label}", response)
        }
        return result
    }

    fun inactiveTimestamp(dateTime: LocalDateTime): String {
        val d = dateTime.date
        val datePart = formatDate(d.year, d.monthNumber, d.dayOfMonth)
        val dayAbbrev = dayAbbreviation(d.dayOfWeek)
        return "[$datePart $dayAbbrev]"
    }

    fun activeTimestamp(dateTime: LocalDateTime): String {
        val d = dateTime.date
        val datePart = formatDate(d.year, d.monthNumber, d.dayOfMonth)
        val dayAbbrev = dayAbbreviation(d.dayOfWeek)
        return "<$datePart $dayAbbrev>"
    }

    fun inactiveTimestampWithTime(dateTime: LocalDateTime): String {
        val d = dateTime.date
        val t = dateTime.time
        val datePart = formatDate(d.year, d.monthNumber, d.dayOfMonth)
        val dayAbbrev = dayAbbreviation(d.dayOfWeek)
        val timePart = formatTime(t.hour, t.minute)
        return "[$datePart $dayAbbrev $timePart]"
    }

    fun activeTimestampWithTime(dateTime: LocalDateTime): String {
        val d = dateTime.date
        val t = dateTime.time
        val datePart = formatDate(d.year, d.monthNumber, d.dayOfMonth)
        val dayAbbrev = dayAbbreviation(d.dayOfWeek)
        val timePart = formatTime(t.hour, t.minute)
        return "<$datePart $dayAbbrev $timePart>"
    }

    fun dateOnly(dateTime: LocalDateTime): String {
        val d = dateTime.date
        return formatDate(d.year, d.monthNumber, d.dayOfMonth)
    }

    private fun formatDate(year: Int, month: Int, day: Int): String {
        return "$year-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}"
    }

    private fun formatTime(hour: Int, minute: Int): String {
        return "${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}"
    }

    private fun dayAbbreviation(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
        DayOfWeek.MONDAY -> "Mon"
        DayOfWeek.TUESDAY -> "Tue"
        DayOfWeek.WEDNESDAY -> "Wed"
        DayOfWeek.THURSDAY -> "Thu"
        DayOfWeek.FRIDAY -> "Fri"
        DayOfWeek.SATURDAY -> "Sat"
        DayOfWeek.SUNDAY -> "Sun"
    }
}

Tests

Both services are tested with pure unit tests in commonTest, no Android dependencies required.

CaptureServiceTest

kotlin#+name: capture-service-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/capture/CaptureServiceTest.kt
package computer.whatthefuck.arcology.capture

import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class CaptureServiceTest {

    @Test
    fun testBuildCaptureEntryBasic() {
        val entry = CaptureService.buildCaptureEntry(
            body = "Hello world",
            id = "abc-123",
            time = LocalTime(14, 30)
        )

        assertContains(entry, "* 14:30")
        assertContains(entry, ":ID:       abc-123")
        assertContains(entry, ":PROPERTIES:")
        assertContains(entry, ":END:")
        assertContains(entry, "Hello world")
    }

    @Test
    fun testBuildCaptureEntryWithTitle() {
        val entry = CaptureService.buildCaptureEntry(
            body = "Some text",
            title = "My Note",
            id = "def-456",
            time = LocalTime(9, 5)
        )

        assertContains(entry, "* My Note")
        // Should NOT contain the time-based default
        assertTrue(!entry.contains("* 09:05"))
    }

    @Test
    fun testBuildCaptureEntryWithTodoState() {
        val entry = CaptureService.buildCaptureEntry(
            body = "task body",
            todoState = "TODO",
            id = "ghi-789",
            time = LocalTime(12, 0)
        )

        assertContains(entry, "* TODO 12:00")
    }

    @Test
    fun testBuildCaptureEntryWithTags() {
        val entry = CaptureService.buildCaptureEntry(
            body = "tagged",
            tags = listOf("work", "project"),
            id = "jkl-012",
            time = LocalTime(8, 0)
        )

        assertContains(entry, ":work:project:")
    }

    @Test
    fun testBuildCaptureEntryWithAliases() {
        val entry = CaptureService.buildCaptureEntry(
            body = "aliased",
            aliases = listOf("Alias One", "Alias Two"),
            id = "mno-345",
            time = LocalTime(16, 45)
        )

        assertContains(entry, ":ROAM_ALIASES: \"Alias One\" \"Alias Two\"")
    }

    @Test
    fun testBuildCaptureEntryTimeDefaultPadding() {
        val entry = CaptureService.buildCaptureEntry(
            body = "early",
            id = "pad-test",
            time = LocalTime(3, 7)
        )

        assertContains(entry, "* 03:07")
    }

    @Test
    fun testBuildDailyFileContent() {
        val content = CaptureService.buildDailyFileContent(
            date = LocalDate(2025, 6, 15),
            fileId = "file-id-123"
        )

        assertContains(content, ":ID:       file-id-123")
        assertContains(content, "#+title: 2025-06-15")
        assertContains(content, ":PROPERTIES:")
        assertContains(content, ":END:")
    }

    @Test
    fun testDailyFileName() {
        val name = CaptureService.dailyFileName(LocalDate(2025, 1, 3))
        assertEquals("2025-01-03.org", name)
    }

    @Test
    fun testBuildCaptureEntryWithRefs() {
        val entry = CaptureService.buildCaptureEntry(
            body = "bookmarked",
            refs = listOf("https://example.com", "https://other.org/page"),
            id = "ref-test",
            time = LocalTime(10, 0)
        )

        assertContains(entry, ":ROAM_REFS: \"https://example.com\" \"https://other.org/page\"")
    }

    @Test
    fun testIsUrl() {
        assertTrue(CaptureService.isUrl("https://example.com"))
        assertTrue(CaptureService.isUrl("http://foo.bar/baz?q=1"))
        assertTrue(CaptureService.isUrl("  https://trimmed.com  "))
        assertTrue(!CaptureService.isUrl("not a url"))
        assertTrue(!CaptureService.isUrl("ftp://other.protocol"))
        assertTrue(!CaptureService.isUrl("https://has spaces.com/foo bar"))
    }

    @Test
    fun testGenerateId() {
        val dt = LocalDateTime(2026, 1, 27, 11, 13, 38)
        val id = CaptureService.generateId(dt, 7294)
        assertEquals("20260127T111338.007294", id)
    }

    @Test
    fun testGenerateIdZeroPadding() {
        val dt = LocalDateTime(2025, 3, 5, 1, 2, 3)
        val id = CaptureService.generateId(dt, 0)
        assertEquals("20250305T010203.000000", id)
    }

    @Test
    fun testBuildCaptureEntryWithFlashcardType() {
        val entry = CaptureService.buildCaptureEntry(
            body = "Flashcard body",
            title = "Flash",
            id = "fc-test",
            time = LocalTime(10, 0),
            flashcardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL
        )

        assertContains(entry, "* Flash :fc:")
        assertContains(entry, ":FC_TYPE:       normal")
        assertTrue(!entry.contains(":FC_CLOZE_TYPE:"))
    }

    @Test
    fun testBuildCaptureEntryWithClozeType() {
        val entry = CaptureService.buildCaptureEntry(
            body = "{{a}@0} {{b}@1}",
            title = "Cloze",
            id = "cloze-test",
            time = LocalTime(14, 30),
            flashcardType = computer.whatthefuck.arcology.domain.FlashcardType.CLOZE,
            clozeType = computer.whatthefuck.arcology.domain.ClozeType.DELETION
        )

        assertContains(entry, "* Cloze :fc:")
        assertContains(entry, ":FC_TYPE:       cloze")
        assertContains(entry, ":FC_CLOZE_TYPE: deletion")
    }

    @Test
    fun `generateId produces different IDs for different microsecond inputs`() {
        val dt = LocalDateTime(2026, 1, 1, 12, 0, 0)
        val id1 = CaptureService.generateId(dt, 1000)
        val id2 = CaptureService.generateId(dt, 1001)
        assertTrue(id1 != id2)
    }

    @Test
    fun testBuildCaptureEntryWithPublishKey() {
        val entry = CaptureService.buildCaptureEntry(
            body = "published body",
            id = "pub-test",
            time = LocalTime(10, 0),
            publish = PublishMetadata(key = "garden/my-page")
        )

        assertContains(entry, ":ARCOLOGY_KEY: garden/my-page")
    }

    @Test
    fun testBuildCaptureEntryWithPublishEmptyKeyOmitsProperty() {
        val entry = CaptureService.buildCaptureEntry(
            body = "body",
            id = "pub-empty",
            time = LocalTime(10, 0),
            publish = PublishMetadata(key = "")
        )

        assertTrue(!entry.contains(":ARCOLOGY_KEY:"))
    }

    @Test
    fun testBuildCaptureEntryWithPublishExpire() {
        val entry = CaptureService.buildCaptureEntry(
            body = "expiring",
            id = "pub-expire",
            time = LocalTime(10, 0),
            publish = PublishMetadata(
                key = "garden/ephemeral",
                expire = "[2026-09-07 Mon 14:00]"
            )
        )

        assertContains(entry, ":ARCOLOGY_EXPIRE: [2026-09-07 Mon 14:00]")
    }

    @Test
    fun testBuildCaptureEntryWithPublishAllowCrawlTrue() {
        val entry = CaptureService.buildCaptureEntry(
            body = "crawl me",
            id = "pub-crawl-t",
            time = LocalTime(10, 0),
            publish = PublishMetadata(key = "garden/open", allowCrawl = true)
        )

        assertContains(entry, ":ARCOLOGY_ALLOW_CRAWL: t")
    }

    @Test
    fun testBuildCaptureEntryWithPublishAllowCrawlFalse() {
        val entry = CaptureService.buildCaptureEntry(
            body = "no crawling",
            id = "pub-crawl-nil",
            time = LocalTime(10, 0),
            publish = PublishMetadata(key = "garden/private", allowCrawl = false)
        )

        assertContains(entry, ":ARCOLOGY_ALLOW_CRAWL: nil")
    }

    @Test
    fun testBuildCaptureEntryWithPublishAllowCrawlNullOmitsProperty() {
        val entry = CaptureService.buildCaptureEntry(
            body = "default crawl",
            id = "pub-crawl-null",
            time = LocalTime(10, 0),
            publish = PublishMetadata(key = "garden/default")
        )

        assertTrue(!entry.contains(":ARCOLOGY_ALLOW_CRAWL:"))
    }

    @Test
    fun testBuildCaptureEntryWithPublishPageTemplate() {
        val entry = CaptureService.buildCaptureEntry(
            body = "wide page",
            id = "pub-template",
            time = LocalTime(10, 0),
            publish = PublishMetadata(key = "garden/wide", pageTemplate = "wide")
        )

        assertContains(entry, ":ARCOLOGY_PAGE_TEMPLATE: wide")
    }

    @Test
    fun testBuildCaptureEntryWithFullPublishMetadata() {
        val entry = CaptureService.buildCaptureEntry(
            body = "everything",
            id = "pub-all",
            time = LocalTime(10, 0),
            publish = PublishMetadata(
                key = "lionsrear/recipes/test",
                expire = "[2026-12-31 Thu 23:59]",
                allowCrawl = true,
                pageTemplate = "topic"
            )
        )

        assertContains(entry, ":ARCOLOGY_KEY: lionsrear/recipes/test")
        assertContains(entry, ":ARCOLOGY_EXPIRE: [2026-12-31 Thu 23:59]")
        assertContains(entry, ":ARCOLOGY_ALLOW_CRAWL: t")
        assertContains(entry, ":ARCOLOGY_PAGE_TEMPLATE: topic")
    }

    @Test
    fun testBuildCaptureEntryWithoutPublishOmitsArcologyProperties() {
        val entry = CaptureService.buildCaptureEntry(
            body = "plain note",
            id = "pub-none",
            time = LocalTime(10, 0)
        )

        assertTrue(!entry.contains(":ARCOLOGY_KEY:"))
        assertTrue(!entry.contains(":ARCOLOGY_EXPIRE:"))
        assertTrue(!entry.contains(":ARCOLOGY_ALLOW_CRAWL:"))
        assertTrue(!entry.contains(":ARCOLOGY_PAGE_TEMPLATE:"))
    }

    @Test
    fun testBuildCaptureEntryWithPublishAndNoId() {
        val entry = CaptureService.buildCaptureEntry(
            body = "published without id",
            time = LocalTime(10, 0),
            publish = PublishMetadata(key = "garden/idless")
        )

        assertContains(entry, ":ARCOLOGY_KEY: garden/idless")
        assertContains(entry, ":PROPERTIES:")
        assertContains(entry, ":END:")
    }
}

TemplateExpanderTest

kotlin#+name: template-expander-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/capture/TemplateExpanderTest.kt
package computer.whatthefuck.arcology.capture

import kotlinx.datetime.LocalDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class TemplateExpanderTest {

    private val testDateTime = LocalDateTime(2026, 2, 2, 15, 30, 0)

    @Test
    fun testEmptyPattern() {
        val result = TemplateExpander.expand("", testDateTime)
        assertEquals("", result.text)
        assertTrue(result.prompts.isEmpty())
    }

    @Test
    fun testNoEscapes() {
        val result = TemplateExpander.expand("Hello world", testDateTime)
        assertEquals("Hello world", result.text)
        assertTrue(result.prompts.isEmpty())
    }

    @Test
    fun testInactiveTimestamp() {
        val result = TemplateExpander.expand("%t", testDateTime)
        assertEquals("[2026-02-02 Mon]", result.text)
    }

    @Test
    fun testActiveTimestamp() {
        val result = TemplateExpander.expand("%T", testDateTime)
        assertEquals("<2026-02-02 Mon>", result.text)
    }

    @Test
    fun testInactiveTimestampWithTime() {
        val result = TemplateExpander.expand("%u", testDateTime)
        assertEquals("[2026-02-02 Mon 15:30]", result.text)
    }

    @Test
    fun testActiveTimestampWithTime() {
        val result = TemplateExpander.expand("%U", testDateTime)
        assertEquals("<2026-02-02 Mon 15:30>", result.text)
    }

    @Test
    fun testDateOnly() {
        val result = TemplateExpander.expand("%d", testDateTime)
        assertEquals("2026-02-02", result.text)
    }

    @Test
    fun testLiteralPercent() {
        val result = TemplateExpander.expand("50%% complete", testDateTime)
        assertEquals("50% complete", result.text)
    }

    @Test
    fun testMultipleEscapes() {
        val result = TemplateExpander.expand("%t Meeting on %d", testDateTime)
        assertEquals("[2026-02-02 Mon] Meeting on 2026-02-02", result.text)
    }

    @Test
    fun testClipboard() {
        val result = TemplateExpander.expand(
            "Clipped: %c",
            testDateTime,
            clipboardProvider = { "clipboard text" }
        )
        assertEquals("Clipped: clipboard text", result.text)
    }

    @Test
    fun testClipboardEmpty() {
        val result = TemplateExpander.expand(
            "Clipped: %c",
            testDateTime,
            clipboardProvider = { null }
        )
        assertEquals("Clipped: ", result.text)
    }

    @Test
    fun testLocation() {
        val result = TemplateExpander.expand(
            "Location: %l",
            testDateTime,
            locationProvider = { "lat:37.7749,lon:-122.4194" }
        )
        assertEquals("Location: lat:37.7749,lon:-122.4194", result.text)
    }

    @Test
    fun testLocationEmpty() {
        val result = TemplateExpander.expand(
            "Location: %l",
            testDateTime,
            locationProvider = { null }
        )
        assertEquals("Location: ", result.text)
    }

    @Test
    fun testSinglePrompt() {
        val result = TemplateExpander.expand(
            "Topic: %^{Topic}",
            testDateTime
        )
        assertEquals("Topic: %^{Topic}", result.text)
        assertEquals(1, result.prompts.size)
        assertEquals("Topic", result.prompts[0].label)
        assertEquals("%^{Topic}", result.prompts[0].placeholder)
    }

    @Test
    fun testMultiplePrompts() {
        val result = TemplateExpander.expand(
            "%^{Title} - by %^{Author}",
            testDateTime
        )
        assertEquals("%^{Title} - by %^{Author}", result.text)
        assertEquals(2, result.prompts.size)
        assertEquals("Title", result.prompts[0].label)
        assertEquals("Author", result.prompts[1].label)
    }

    @Test
    fun testApplyPromptResponses() {
        val pattern = "Topic: %^{Topic}, Category: %^{Category}"
        val responses = mapOf(
            "Topic" to "Kotlin",
            "Category" to "Programming"
        )
        val result = TemplateExpander.applyPromptResponses(pattern, responses)
        assertEquals("Topic: Kotlin, Category: Programming", result)
    }

    @Test
    fun testComplexPattern() {
        val result = TemplateExpander.expand(
            "%t %^{What} - 100%% complete",
            testDateTime,
            clipboardProvider = { "clip" }
        )
        assertEquals("[2026-02-02 Mon] %^{What} - 100% complete", result.text)
        assertEquals(1, result.prompts.size)
        assertEquals("What", result.prompts[0].label)
    }

    @Test
    fun testTimestampWithTextAround() {
        val result = TemplateExpander.expand(
            "Created on %t for review",
            testDateTime
        )
        assertEquals("Created on [2026-02-02 Mon] for review", result.text)
    }

    @Test
    fun testInactiveTimestampFunction() {
        val result = TemplateExpander.inactiveTimestamp(testDateTime)
        assertEquals("[2026-02-02 Mon]", result)
    }

    @Test
    fun testActiveTimestampFunction() {
        val result = TemplateExpander.activeTimestamp(testDateTime)
        assertEquals("<2026-02-02 Mon>", result)
    }

    @Test
    fun testDateOnlyFunction() {
        val result = TemplateExpander.dateOnly(testDateTime)
        assertEquals("2026-02-02", result)
    }

    @Test
    fun testDayAbbreviations() {
        val sunday = LocalDateTime(2026, 2, 1, 12, 0, 0)
        assertEquals("[2026-02-01 Sun]", TemplateExpander.inactiveTimestamp(sunday))

        val tuesday = LocalDateTime(2026, 2, 3, 12, 0, 0)
        assertEquals("[2026-02-03 Tue]", TemplateExpander.inactiveTimestamp(tuesday))

        val saturday = LocalDateTime(2026, 2, 7, 12, 0, 0)
        assertEquals("[2026-02-07 Sat]", TemplateExpander.inactiveTimestamp(saturday))
    }

    @Test
    fun testZeroPaddedDateAndTime() {
        val dt = LocalDateTime(2026, 1, 5, 3, 7, 0)
        val result = TemplateExpander.expand("%u", dt)
        assertEquals("[2026-01-05 Mon 03:07]", result.text)
    }

    @Test
    fun testPromptWithSpaces() {
        val result = TemplateExpander.expand(
            "%^{Full Name}",
            testDateTime
        )
        assertEquals(1, result.prompts.size)
        assertEquals("Full Name", result.prompts[0].label)
    }

    @Test
    fun testMultilinePattern() {
        val result = TemplateExpander.expand(
            "%t\n- First item\n- %^{Second}",
            testDateTime
        )
        assertTrue(result.text.contains("[2026-02-02 Mon]"))
        assertTrue(result.text.contains("- First item"))
        assertTrue(result.text.contains("- %^{Second}"))
        assertEquals(1, result.prompts.size)
    }

    @Test
    fun testPageTitleEscape() {
        val result = TemplateExpander.expand(
            "%page-title",
            testDateTime,
            pageTitleProvider = { "My Page Title" }
        )
        assertEquals("My Page Title", result.text)
    }

    @Test
    fun testPageTitleEscapeEmpty() {
        val result = TemplateExpander.expand(
            "%page-title",
            testDateTime,
            pageTitleProvider = { null }
        )
        assertEquals("", result.text)
    }

    @Test
    fun testPageTitleWithTimestamp() {
        val result = TemplateExpander.expand(
            "%u %page-title",
            testDateTime,
            pageTitleProvider = { "Hello World" }
        )
        assertEquals("[2026-02-02 Mon 15:30] Hello World", result.text)
    }
}

Related Modules