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 aLocalDateasYYYY-MM-DD.org.generateId()— generate a datetime-based UUID string (20260127T111338.007294) fromLocalDateTime+ 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
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 inPromptRequestlist)%%— literal percent sign
TemplateExpander — pure template expansion engine
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
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
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
Used by
OrgDocumentEditorViewModelfrom viewmodel.orgCaptureTemplatedata model in app/data.orgFlashcardTypeandClozeTypeenums in quiz/models.org