Introduction
The TaskIndexerPlugin is the bridge between the orgmode-kmp AST and the AgendaRepository. It runs during FlowFileIndexer indexing, alongside QuizIndexerPlugin and ArroyoIndexerPlugin, and populates the task_headings, task_timestamps, and task_state_history tables defined in agenda/models.org.
Unlike the quiz plugin (which filters result.nodes by a property key), the task plugin walks the AST directly — because org-agenda operates on all headings, not just nodes with :ID: properties. A heading without an :ID: is not an org-roam node, but it can still be a task or an event. The plugin follows the ArroyoIndexerPlugin pattern: recursive section walking, per-file delete-then-insert, and proper onFileRemoved cleanup.
Design Decisions
Walk result.document?.content, not result.sections.
ParseResult.Success carries the full parsed OrgDocument (nullable — null for the blank-content early-return path). The plugin walks result.document?.content — the top-level sections, with children nested in each section's body — recursively, descending into section.body.filterIsInstance<OrgSection>() for child headings. This matches how ArroyoIndexerPlugin and the parser's own extractNodesFromSectionInternal work.
Position is the heading's token range start, not a sequential counter.
The parser's position field is a sequential counter shared by nodes and links, not a byte offset. For task_headings, we use section.heading.tokens.first().range.first — the character offset of the heading's * tokens in the source. This is a true, stable byte offset that survives re-indexing and is unique per file. This pattern is already used in NodeContentParser.kt:144 (section.heading.tokens.first().range.first) for heading position lookup in the editor.
Filetags come from result.tags via the file-level node.
The parser adds #+FILETAGS: to the file-level node (level 0) as OrgTag entries. Since org-roam files always have a file-level :ID:, the plugin extracts filetags by filtering result.tags for the file-level node's ID. These inherit to every task heading in the file, alongside the heading's own inline tags from section.heading.tags.
Heading tags from the AST, not from result.tags.
For non-node headings, the parser attributes tags to the nearest ancestor node (in result.tags), not to the heading itself. Since the plugin walks the AST and has direct access to section.heading.tags?.tags, it collects heading tags from the AST directly — this gives every task heading its own tag list, independent of whether it has an :ID:.
Timestamps: SCHEDULED/DEADLINE from planning info, FLOATING from the body.
SCHEDULEDandDEADLINEcome fromsection.heading.planningInfo.scheduled/.deadline(OrgInlineElem.DTStamp).FLOATINGtimestamps are activeDTStampelements found in the section body (paragraphs, lists, blocks), collected by a recursive chunk/inline walker that mirrors the parser'sextractLinksFromInlineElems. The heading title is not scanned — only the body — to match the user's preference.Each timestamp stores both an ISO date string (
dtStamp.date.toString()) forBETWEENrange queries and an epoch-second integer for time-of-day ordering. The epoch is computed fromDTStamp.date+DTStamp.timeviaTimeZone.UTC; whentimeis null (noHH:MMin the org-mode timestamp),tsis null.
LOGBOOK state changes from the AST OrgLogbookDrawer.
The orgmode-kmp parser parses :LOGBOOK: drawers into OrgChunk.OrgLogbookDrawer objects with structured OrgLogbookEntry.StateChange entries (fromState, toState, timestamp: Instant). The plugin finds these with section.body.filterIsInstance<OrgChunk.OrgLogbookDrawer>() and inserts each StateChange into task_state_history. No regex parsing is needed — the data is already structured by the parser.
onFileIndexed runs inside the transaction; onFileRemoved cleans up.
FlowFileIndexer.storeParseResultBatched calls plugin.onFileIndexed(result) inside its repository.transaction { } block, so the plugin can safely delete-then-insert per file. onFileRemoved runs outside any transaction; the plugin implements it to delete all four task tables by file path (cascade handles task_timestamps and task_tags, but they are deleted explicitly alongside task_state_history for clarity).
Implementation
The plugin is a single class implementing IndexerPlugin. It takes an AgendaRepository and walks result.document?.content recursively, extracting task headings, timestamps, and LOGBOOK state changes.
package computer.whatthefuck.arcology.agenda
import computer.whatthefuck.arcology.database.AgendaRepository
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.domain.TaskTimestamp
import computer.whatthefuck.arcology.domain.TaskTimestampKind
import computer.whatthefuck.arcology.domain.TaskStateChange
import computer.whatthefuck.arcology.indexer.IndexerPlugin
import computer.whatthefuck.arcology.parser.ParseResult
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import xyz.lepisma.orgmode.OrgBlock
import xyz.lepisma.orgmode.OrgChunk
import xyz.lepisma.orgmode.OrgInlineElem
import xyz.lepisma.orgmode.OrgList
import xyz.lepisma.orgmode.OrgLogbookEntry
import xyz.lepisma.orgmode.OrgSection
import xyz.lepisma.orgmode.plainText
class TaskIndexerPlugin(
private val repository: AgendaRepository
) : IndexerPlugin {
override suspend fun onFileIndexed(result: ParseResult.Success) {
val filePath = result.file.path
repository.deleteTaskHeadingsByFile(filePath)
repository.deleteTaskTimestampsByFile(filePath)
repository.deleteTaskTagsByFile(filePath)
repository.deleteTaskStateChangesByFile(filePath)
val fileTags = extractFileTags(result)
result.document?.content?.forEach { section ->
extractTaskHeadingsFromSection(section, filePath, fileTags, emptyList())
}
}
override suspend fun onFileRemoved(filePath: String) {
repository.deleteTaskHeadingsByFile(filePath)
repository.deleteTaskTimestampsByFile(filePath)
repository.deleteTaskTagsByFile(filePath)
repository.deleteTaskStateChangesByFile(filePath)
}
private suspend fun extractTaskHeadingsFromSection(
section: OrgSection,
filePath: String,
fileTags: List<String>,
parentPath: List<String>
) {
val heading = section.heading
val title = heading.title.plainText().trim()
val outlinePath = parentPath + title
val position = heading.tokens.first().range.first
val nodeId = extractNodeId(heading)
val props = extractProperties(heading)
val isHabit = props["STYLE"]?.equals("habit", ignoreCase = true) ?: false
val tags = ((heading.tags?.tags ?: emptyList()) + fileTags).distinct()
val todo = heading.todoState?.text
val priority = heading.priority?.priority?.toString()
val timestamps = mutableListOf<TaskTimestamp>()
heading.planningInfo?.scheduled?.let { dtStamp ->
timestamps.add(dtStampToTimestamp(dtStamp, filePath, position, TaskTimestampKind.SCHEDULED))
}
heading.planningInfo?.deadline?.let { dtStamp ->
timestamps.add(dtStampToTimestamp(dtStamp, filePath, position, TaskTimestampKind.DEADLINE))
}
collectActiveTimestampsFromBody(section.body).forEach { dtStamp ->
timestamps.add(dtStampToTimestamp(dtStamp, filePath, position, TaskTimestampKind.FLOATING))
}
val isTask = todo != null || timestamps.isNotEmpty() || isHabit
if (isTask) {
repository.insertTaskHeading(
TaskHeading(
file = filePath,
position = position,
level = heading.level.level,
todo = todo,
priority = priority,
title = title,
outlinePath = outlinePath,
nodeId = nodeId,
tags = tags,
isHabit = isHabit,
timestamps = timestamps
)
)
}
extractLogbookEntries(section, filePath, position)
section.body.filterIsInstance<OrgSection>().forEach { childSection ->
extractTaskHeadingsFromSection(childSection, filePath, fileTags, outlinePath)
}
}
private suspend fun extractLogbookEntries(section: OrgSection, filePath: String, position: Int) {
section.body.filterIsInstance<OrgChunk.OrgLogbookDrawer>().forEach { drawer ->
drawer.entries.filterIsInstance<OrgLogbookEntry.StateChange>().forEach { change ->
repository.insertTaskStateChange(
TaskStateChange(
id = 0,
file = filePath,
position = position,
fromState = change.fromState,
toState = change.toState,
timestamp = change.timestamp
)
)
}
}
}
private fun extractFileTags(result: ParseResult.Success): List<String> {
val fileLevelNode = result.nodes.firstOrNull { it.level == 0 } ?: return emptyList()
return result.tags.filter { it.nodeId == fileLevelNode.id }.map { it.tag }.distinct()
}
private fun extractNodeId(heading: xyz.lepisma.orgmode.OrgHeading): String? {
return heading.properties?.map?.get("ID")?.let { idLine ->
idLine.items.filterIsInstance<OrgInlineElem.Text>()
.joinToString("") { it.text }
.trim()
.takeIf { it.isNotEmpty() }
}
}
private fun extractProperties(heading: xyz.lepisma.orgmode.OrgHeading): Map<String, String> {
return heading.properties?.map?.mapValues { (_, orgLine) ->
orgLine.items.filterIsInstance<OrgInlineElem.Text>()
.joinToString("") { it.text }
.trim()
} ?: emptyMap()
}
private fun dtStampToTimestamp(
dtStamp: OrgInlineElem.DTStamp,
file: String,
position: Int,
kind: TaskTimestampKind
): TaskTimestamp {
return TaskTimestamp(
file = file,
position = position,
kind = kind,
date = dtStamp.date.toString(),
timestamp = toEpochSeconds(dtStamp),
repeater = dtStamp.repeater
)
}
private fun toEpochSeconds(dtStamp: OrgInlineElem.DTStamp): Long? {
val timePair = dtStamp.time ?: return null
val localDateTime = LocalDateTime(dtStamp.date, timePair.first)
return localDateTime.toInstant(TimeZone.UTC).epochSeconds
}
private fun collectActiveTimestampsFromBody(body: List<OrgChunk>): List<OrgInlineElem.DTStamp> {
val stamps = mutableListOf<OrgInlineElem.DTStamp>()
body.forEach { chunk ->
if (chunk !is OrgSection) {
collectActiveTimestampsFromChunk(chunk, stamps)
}
}
return stamps
}
private fun collectActiveTimestampsFromChunk(chunk: OrgChunk, out: MutableList<OrgInlineElem.DTStamp>) {
when (chunk) {
is OrgChunk.OrgParagraph -> collectActiveTimestampsFromInlineElems(chunk.items, out)
is OrgBlock.OrgQuoteBlock -> chunk.body.forEach { collectActiveTimestampsFromChunk(it, out) }
is OrgBlock.OrgCenterBlock -> chunk.body.forEach { collectActiveTimestampsFromChunk(it, out) }
is OrgBlock.OrgPageIntroBlock -> chunk.body.forEach { collectActiveTimestampsFromChunk(it, out) }
is OrgBlock.OrgEditsBlock -> chunk.body.forEach { collectActiveTimestampsFromChunk(it, out) }
is OrgBlock.OrgAsideBlock -> chunk.body.forEach { collectActiveTimestampsFromChunk(it, out) }
is OrgBlock.OrgVideoBlock -> chunk.body.forEach { collectActiveTimestampsFromChunk(it, out) }
is OrgList.OrgUnorderedList -> chunk.items.forEach { item ->
item.content.forEach { collectActiveTimestampsFromChunk(it, out) }
}
is OrgList.OrgOrderedList -> chunk.items.forEach { item ->
item.content.forEach { collectActiveTimestampsFromChunk(it, out) }
}
else -> { }
}
}
private fun collectActiveTimestampsFromInlineElems(
elems: List<OrgInlineElem>,
out: MutableList<OrgInlineElem.DTStamp>
) {
elems.forEach { elem ->
when (elem) {
is OrgInlineElem.DTStamp -> if (elem.isActive) out.add(elem)
is OrgInlineElem.DTRange -> {
if (elem.start.isActive) out.add(elem.start)
if (elem.end.isActive) out.add(elem.end)
}
is OrgInlineElem.Bold -> collectActiveTimestampsFromInlineElems(elem.content, out)
is OrgInlineElem.Italic -> collectActiveTimestampsFromInlineElems(elem.content, out)
is OrgInlineElem.Underline -> collectActiveTimestampsFromInlineElems(elem.content, out)
is OrgInlineElem.StrikeThrough -> collectActiveTimestampsFromInlineElems(elem.content, out)
is OrgInlineElem.Verbatim -> collectActiveTimestampsFromInlineElems(elem.content, out)
is OrgInlineElem.Code -> collectActiveTimestampsFromInlineElems(elem.content, out)
is OrgInlineElem.Link -> elem.title?.let { collectActiveTimestampsFromInlineElems(it, out) }
else -> { }
}
}
}
}Tests
The plugin tests follow the ArroyoIndexerPluginTest pattern: parse org content with OrgFileParser, create an in-memory database with DatabaseFactory.createInMemoryDatabase(), run plugin.onFileIndexed(result), then query the AgendaRepository to assert the extracted data. All org-content fixtures inside the source block use comma-escaping for * headings and #+ lines so org-mode does not interpret them as structural elements.
package computer.whatthefuck.arcology.agenda
import computer.whatthefuck.arcology.database.AgendaRepositoryImpl
import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.domain.TaskTimestampKind
import computer.whatthefuck.arcology.indexer.IndexerPlugin
import computer.whatthefuck.arcology.parser.OrgFileParser
import computer.whatthefuck.arcology.parser.ParseResult
import kotlinx.coroutines.test.runTest
import kotlin.test.*
class TaskIndexerPluginTest {
private val parser = OrgFileParser()
@Test
fun `extracts SCHEDULED timestamp from planning info`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Task with SCHEDULED
SCHEDULED: <2026-07-31 Fri 09:00>
:PROPERTIES:
:ID: task-scheduled-id
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success, "Parse should succeed: $parseResult")
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size, "Should extract one task heading")
val task = tasks.first()
assertEquals("TODO", task.todo)
assertEquals(1, task.timestamps.size, "Should have one timestamp")
val ts = task.timestamps.first()
assertEquals(TaskTimestampKind.SCHEDULED, ts.kind)
assertEquals("2026-07-31", ts.date)
assertNotNull(ts.timestamp, "Should have epoch seconds for time-of-day")
}
@Test
fun `extracts DEADLINE timestamp without time-of-day`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Task with DEADLINE
DEADLINE: <2026-08-15 Sat>
:PROPERTIES:
:ID: task-deadline-id
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val ts = tasks.first().timestamps.first()
assertEquals(TaskTimestampKind.DEADLINE, ts.kind)
assertEquals("2026-08-15", ts.date)
assertNull(ts.timestamp, "Should have null epoch when no time-of-day")
}
@Test
fun `extracts floating active timestamp from body`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Meeting
:PROPERTIES:
:ID: meeting-id
:END:
Meeting at <2026-07-31 Fri 14:00> in the conference room.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val floating = tasks.first().timestamps.filter { it.kind == TaskTimestampKind.FLOATING }
assertEquals(1, floating.size, "Should extract one floating timestamp")
assertEquals("2026-07-31", floating.first().date)
assertNotNull(floating.first().timestamp, "Should have epoch for time-of-day")
}
@Test
fun `does not extract inactive timestamps from body`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Task with inactive timestamp
:PROPERTIES:
:ID: task-inactive-id
:END:
Recorded at [2026-07-31 Fri 14:00] but this is inactive.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val floating = tasks.first().timestamps.filter { it.kind == TaskTimestampKind.FLOATING }
assertTrue(floating.isEmpty(), "Should not extract inactive timestamps")
}
@Test
fun `extracts multiple floating timestamps from body`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Multi-event task
:PROPERTIES:
:ID: multi-event-id
:END:
First event <2026-07-31 Fri 10:00> and second event <2026-08-01 Sat 15:00>.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val floating = tasks.first().timestamps.filter { it.kind == TaskTimestampKind.FLOATING }
assertEquals(2, floating.size, "Should extract two floating timestamps")
}
@Test
fun `extracts LOGBOOK state changes`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Task with logbook
:PROPERTIES:
:ID: logbook-task-id
:END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-07-31 Fri 10:00]
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val task = tasks.first()
val history = repo.getTaskStateHistory(task.file, task.position)
assertEquals(1, history.size, "Should extract one state change")
val change = history.first()
assertEquals("TODO", change.fromState)
assertEquals("DONE", change.toState)
}
@Test
fun `extracts multiple LOGBOOK state changes`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Task with multiple state changes
:PROPERTIES:
:ID: multi-logbook-id
:END:
:LOGBOOK:
- State "TODO" from "DONE" [2026-07-30 Thu 15:00]
- State "DONE" from "TODO" [2026-07-31 Fri 10:00]
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val history = repo.getTaskStateHistory(tasks.first().file, tasks.first().position)
assertEquals(2, history.size, "Should extract two state changes")
}
@Test
fun `extracts habit with STYLE property`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Daily review
SCHEDULED: <2026-07-31 Fri>
:PROPERTIES:
:ID: habit-id
:STYLE: habit
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success, "Parse failed: ${(parseResult as? ParseResult.ParseError)?.error}")
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val allTasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, allTasks.size, "Should extract one task heading")
assertTrue(allTasks.first().isHabit, "isHabit should be true")
val habits = repo.getHabits()
assertEquals(1, habits.size, "Should extract one habit")
assertTrue(habits.first().isHabit, "isHabit should be true")
}
@Test
fun `extracts task heading with TODO state but no timestamps`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Do something
:PROPERTIES:
:ID: todo-only-id
:END:
Just a TODO with no scheduling.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
assertEquals("TODO", tasks.first().todo)
assertTrue(tasks.first().timestamps.isEmpty(), "Should have no timestamps")
}
@Test
fun `does not extract non-task heading without TODO or timestamps`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* Just a heading
:PROPERTIES:
:ID: plain-heading-id
:END:
This heading has no TODO, no timestamps, no habit style.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertTrue(tasks.isEmpty(), "Should not extract non-task headings")
}
@Test
fun `extracts headings without ID as non-node task headings`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Task without ID
Just a TODO with no :PROPERTIES: drawer.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
assertNull(tasks.first().nodeId, "nodeId should be null for non-node headings")
}
@Test
fun `inherits filetags from file-level node`() = runTest {
val orgContent = """:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
#+FILETAGS: :project:work:
,* TODO Task in project
:PROPERTIES:
:ID: task-in-project-id
:END:
Do work.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val tags = tasks.first().tags
assertTrue(tags.contains("project"), "Should inherit filetag 'project'")
assertTrue(tags.contains("work"), "Should inherit filetag 'work'")
}
@Test
fun `collects heading tags from AST`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Buy groceries :personal:errands:
:PROPERTIES:
:ID: groceries-id
:END:
Milk and eggs.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val tags = tasks.first().tags
assertTrue(tags.contains("personal"), "Should have heading tag 'personal'")
assertTrue(tags.contains("errands"), "Should have heading tag 'errands'")
}
@Test
fun `walks nested sections recursively`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* Project container
:PROPERTIES:
:ID: project-container-id
:END:
,** TODO Subtask one
:PROPERTIES:
:ID: subtask-one-id
:END:
First subtask.
,** TODO Subtask two
:PROPERTIES:
:ID: subtask-two-id
:END:
Second subtask.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(2, tasks.size, "Should extract two child TODO tasks (parent has no TODO)")
val titles = tasks.map { it.title }.filterNotNull().sorted()
assertEquals(listOf("Subtask one", "Subtask two"), titles)
val subtaskOne = tasks.first { it.title == "Subtask one" }
assertEquals(listOf("Project container", "Subtask one"), subtaskOne.outlinePath, "Should have outline path from parent")
}
@Test
fun `uses heading token range as position`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO First task
:PROPERTIES:
:ID: first-task-id
:END:
,* TODO Second task
:PROPERTIES:
:ID: second-task-id
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(2, tasks.size)
val firstPos = tasks.first { it.title == "First task" }.position
val secondPos = tasks.first { it.title == "Second task" }.position
assertTrue(firstPos > 0, "Position should be a byte offset, not 0")
assertTrue(secondPos > firstPos, "Second heading should have larger offset")
}
@Test
fun `onFileRemoved deletes all task data`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Task to remove
SCHEDULED: <2026-07-31 Fri 09:00>
:PROPERTIES:
:ID: remove-task-id
:END:
:LOGBOOK:
- State "TODO" from "DONE" [2026-07-30 Thu 15:00]
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
assertEquals(1, repo.getTaskHeadingsByFile("/test.org").size)
plugin.onFileRemoved("/test.org")
assertTrue(repo.getTaskHeadingsByFile("/test.org").isEmpty(), "Task headings should be deleted")
}
@Test
fun `re-indexing replaces task data without duplicates`() = runTest {
val orgContentV1 = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Original task
SCHEDULED: <2026-07-31 Fri 09:00>
:PROPERTIES:
:ID: original-task-id
:END:
"""
val orgContentV2 = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* DONE Original task
SCHEDULED: <2026-07-31 Fri 09:00>
:PROPERTIES:
:ID: original-task-id
:END:
"""
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
val result1 = parser.parseFileContent("/test.org", orgContentV1, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(result1 is ParseResult.Success)
plugin.onFileIndexed(result1 as ParseResult.Success)
val tasksV1 = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasksV1.size)
assertEquals("TODO", tasksV1.first().todo)
val result2 = parser.parseFileContent("/test.org", orgContentV2, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(result2 is ParseResult.Success)
plugin.onFileIndexed(result2 as ParseResult.Success)
val tasksV2 = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasksV2.size, "Should replace, not duplicate")
assertEquals("DONE", tasksV2.first().todo, "Should reflect updated TODO state")
}
@Test
fun `extracts SCHEDULED timestamp with daily repeater`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Daily standup
SCHEDULED: <2026-07-31 Fri 09:00 +1d>
:PROPERTIES:
:ID: standup-id
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val ts = tasks.first().timestamps.first()
assertEquals(TaskTimestampKind.SCHEDULED, ts.kind)
assertEquals("2026-07-31", ts.date)
assertNotNull(ts.timestamp, "Should have epoch for time-of-day")
}
@Test
fun `extracts SCHEDULED timestamp with weekly catch-up repeater`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Weekly review
SCHEDULED: <2026-07-31 Fri +1w>
:PROPERTIES:
:ID: weekly-id
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val ts = tasks.first().timestamps.first()
assertEquals(TaskTimestampKind.SCHEDULED, ts.kind)
assertEquals("2026-07-31", ts.date)
}
@Test
fun `extracts DEADLINE timestamp with repeater`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Pay rent
DEADLINE: <2026-08-01 Sat +1m>
:PROPERTIES:
:ID: rent-id
:END:
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val ts = tasks.first().timestamps.first()
assertEquals(TaskTimestampKind.DEADLINE, ts.kind)
assertEquals("2026-08-01", ts.date)
}
@Test
fun `extracts active date range as two floating timestamps`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Conference
:PROPERTIES:
:ID: conference-id
:END:
Event runs from <2026-07-31 Fri 09:00>--<2026-08-02 Sun 17:00>
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val floating = tasks.first().timestamps.filter { it.kind == TaskTimestampKind.FLOATING }
assertEquals(2, floating.size, "Date range should produce two floating timestamps (start and end)")
val dates = floating.map { it.date }.sorted()
assertEquals(listOf("2026-07-31", "2026-08-02"), dates)
}
@Test
fun `extracts single-day active date range as two timestamps with same date`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Workshop
:PROPERTIES:
:ID: workshop-id
:END:
Session <2026-07-31 Fri 09:00>--<2026-07-31 Fri 17:00>
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val floating = tasks.first().timestamps.filter { it.kind == TaskTimestampKind.FLOATING }
assertEquals(2, floating.size, "Single-day range should still produce two timestamps")
assertEquals("2026-07-31", floating[0].date)
assertEquals("2026-07-31", floating[1].date)
}
@Test
fun `does not extract inactive date range`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Logged session
:PROPERTIES:
:ID: logged-id
:END:
Clocked [2026-07-31 Fri 09:00]--[2026-07-31 Fri 17:00]
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val floating = tasks.first().timestamps.filter { it.kind == TaskTimestampKind.FLOATING }
assertTrue(floating.isEmpty(), "Inactive date range should not produce floating timestamps")
}
@Test
fun `extracts floating timestamp with repeater from body`() = runTest {
val orgContent = """
:PROPERTIES:
:ID: test-file-id
:END:
#+TITLE: Test File
,* TODO Medication reminder
:PROPERTIES:
:ID: med-id
:END:
Take medicine <2026-07-31 Fri 08:00 +1d>.
"""
val parseResult = parser.parseFileContent("/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
assertTrue(parseResult is ParseResult.Success)
val database = DatabaseFactory.createInMemoryDatabase()
val repo = AgendaRepositoryImpl(database)
val plugin: IndexerPlugin = TaskIndexerPlugin(repo)
plugin.onFileIndexed(parseResult as ParseResult.Success)
val tasks = repo.getTaskHeadingsByFile("/test.org")
assertEquals(1, tasks.size)
val floating = tasks.first().timestamps.filter { it.kind == TaskTimestampKind.FLOATING }
assertEquals(1, floating.size, "Should extract floating timestamp with repeater")
assertEquals("2026-07-31", floating.first().date)
assertNotNull(floating.first().timestamp, "Should have epoch for time-of-day")
}
}Related Modules
agenda/index.org — overview and module index for the agenda rebuild
agenda/models.org — TaskHeading, TaskTimestamp, TaskStateChange domain models; AgendaRepository; Agenda.sq schema
roam/indexer.org — FlowFileIndexer pipeline; IndexerPlugin interface; where the plugin is wired in
roam/models.org — OrgNode, OrgTag domain models consumed for filetag extraction
roam/parser.org — OrgFileParser; ParseResult.Success with
sectionsfield