The editor package is the single interface every ViewModel uses to modify org files. It consolidates duplicated editing logic from capture, quiz, search, and settings ViewModels into a shared, testable component.
All operations follow the same pattern: read file → find heading → modify → write → re-index.
The editor depends on the org-mode parser (parser.org), the database repository (models.org), and the file system / indexer (indexer.org).
Design Decisions
Why a separate editor package?
The same transformation operations (set property, change TODO state, refile heading) were duplicated across capture, quiz, and settings ViewModels. Extracting them into a single service means:
One test suite covers all editing operations.
ViewModels only call high-level operations like
setProperty(nodeId, "GEO_COORDS", "37.77, -122.41").The filesystem interface is abstracted away — the editor doesn't care if it's SAF on Android or NIO on JVM.
Text surgery vs. AST rewrite
The editor performs precise string operations on raw file content rather than full AST round-trips. This is a deliberate trade-off:
Full AST → text would require re-serializing the entire document, losing comments, blank lines, and formatting.
Raw string edits preserve the user's exact formatting (including whitespace quirks).
The cost is that operations are brittle — heading position, property drawer boundaries, and tag placement must be computed correctly.
HeadingTextUtils and PropertyDrawerUtils exist because OrgDocumentEditor grew too large. The heading-level concerns (body replacement, refile, level adjustment) split naturally from the property-level concerns (drawer CRUD).
Regexp soup — acknowledged technical debt
Several operations use regex for heading parsing and tag manipulation. These are marked with inline TODO comments. Once the editor is fully documented, we should replace the regex-based heading parser with proper org-mode-kmp token positions.
The Write Interface — OrgDocumentEditor
OrgDocumentEditor is the public API. It takes a FileSystemInterface, a RoamRepository, and a FileIndexingService. Every editing operation is a suspend fun that returns an EditResult (Success with updated content, or Error with message).
package computer.whatthefuck.arcology.editor
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.FileIndexingService
import computer.whatthefuck.arcology.indexer.FileSystemInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
private const val TAG = "OrgDocumentEditor"
/**
* Result of a document editing operation.
*/
sealed class EditResult {
data class Success(val updatedContent: String) : EditResult()
data class Error(val message: String) : EditResult()
}
/**
* Multiplatform service for editing org-mode documents.
* Consolidates duplicated editing logic from ViewModels into a shared, testable component.
*
* This service handles:
* - Property operations (set/remove)
* - Body editing
* - Heading refile operations
*
* All operations follow the pattern: read file -> find heading -> modify -> write -> re-index
*/
class OrgDocumentEditor(
private val fileSystem: FileSystemInterface,
private val repository: RoamRepository,
private val indexingService: FileIndexingService,
private val defaultRepeatToState: String = "TODO"
) {
/**
* Update the TODO state of a heading.
*
* In addition to rewriting the heading keyword, this appends a
* =:LOGBOOK:= state-change entry recording the transition — mirroring
* org-mode's =org-log-into-drawer= behavior.
*
* @param nodeId The node ID to modify
* @param todoState The new TODO state (e.g., "TODO", "DONE", "CANCELLED") or null to remove
* @return EditResult indicating success or failure
*/
suspend fun updateTodoState(nodeId: String, todoState: String?): EditResult {
return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
if (nodeLevel == 0) {
// Level-0 nodes can't have planning info / repeaters; do simple rewrite
return@modifyAndPersist updateTodoStateInHeading(content, headingPosition, todoState)
}
val currentFrom = readCurrentTodoState(content, headingPosition)
val doneStates = setOf("DONE", "CANCELLED", "ARCHIVED")
// If entering a done state, check for repeater reschedule
if (todoState != null && todoState in doneStates && todoState != currentFrom) {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val reschedule = PlanningInfoUtils.computeReschedule(content, headingPosition, today, defaultRepeatToState)
if (reschedule != null) {
// Reschedule: advance the planning timestamp, flip TODO to the reschedule target
var modified = updateTodoStateInHeading(reschedule.newContent, headingPosition, reschedule.newTodoState)
// Log the transition matching Emacs org-log-into-drawer:
// - State "DONE" from "NEXT" [timestamp]
// The user's action (todoState, e.g. DONE) is the toState;
// the previous active state (currentFrom, e.g. NEXT) is the fromState.
modified = appendLogbookEntry(
modified,
headingPosition,
fromState = currentFrom ?: "nil",
toState = todoState
)
return@modifyAndPersist modified
}
}
// No repeater, or not entering done state: simple rewrite + optional log
var modified = updateTodoStateInHeading(content, headingPosition, todoState)
if (todoState != null && todoState != currentFrom) {
modified = appendLogbookEntry(
modified,
headingPosition,
fromState = currentFrom ?: "nil",
toState = todoState
)
}
modified
}
}
/**
* Read the current TODO keyword from a heading line, or null if none.
*/
private fun readCurrentTodoState(content: String, headingPosition: Int): String? {
val headingLineEnd = content.indexOf('\n', headingPosition).let { if (it == -1) content.length else it }
val headingLine = content.substring(headingPosition, headingLineEnd)
val starMatch = Regex("^(\\*+)\\s+").find(headingLine) ?: return null
val afterStars = headingLine.substring(starMatch.range.last + 1)
val todoMatch = Regex("^([A-Z]+)\\s+").find(afterStars)
return if (todoMatch != null && isTodoKeyword(todoMatch.groupValues[1])) {
todoMatch.groupValues[1]
} else {
null
}
}
/**
* Update the TODO state in a heading line.
*/
private fun updateTodoStateInHeading(content: String, headingPosition: Int, todoState: String?): String {
val headingLineEnd = content.indexOf('\n', headingPosition).let { if (it == -1) content.length else it }
val headingLine = content.substring(headingPosition, headingLineEnd)
// Parse current heading: * [*] [TODO_STATE] TITLE [:TAGS:]
val starMatch = Regex("^(\\*+)\\s+").find(headingLine) ?: return content
val stars = starMatch.groupValues[1]
val afterStars = headingLine.substring(starMatch.range.last + 1)
// Check if there's a TODO state already
val todoMatch = Regex("^([A-Z]+)\\s+").find(afterStars)
val hasTodo = todoMatch != null && isTodoKeyword(todoMatch.groupValues[1])
val titleAndTags = if (hasTodo) {
afterStars.substring(todoMatch.range.last + 1)
} else {
afterStars
}
// Build new heading line
val newHeadingLine = buildString {
append(stars)
append(' ')
if (todoState != null) {
append(todoState)
append(' ')
}
append(titleAndTags)
}
return content.substring(0, headingPosition) + newHeadingLine + content.substring(headingLineEnd)
}
/**
* Check if a string is a valid TODO keyword.
*/
private fun isTodoKeyword(text: String): Boolean {
return text in setOf("TODO", "NEXT", "INPROGRESS", "DONE", "CANCELLED", "ARCHIVED")
}
/**
* Find the character position of a heading in file content by its ID property.
*
* @param content The file content to search
* @param nodeId The node ID to find
* @param nodeLevel The level of the node (0 for file-level)
* @return Character offset of the heading start, or null if not found
*/
fun findHeadingPosition(content: String, nodeId: String, nodeLevel: Int): Int? {
// For level-0 nodes (file-level), position is always 0
if (nodeLevel == 0) return 0
// Search for the ID property
val idPattern = Regex(":ID:\\s*${Regex.escape(nodeId)}\\s*$", RegexOption.MULTILINE)
val idMatch = idPattern.find(content) ?: return null
// Walk backwards to find the heading line
val beforeId = content.substring(0, idMatch.range.first)
val headingPattern = Regex("^\\*+ .*$", RegexOption.MULTILINE)
val lastHeadingMatch = headingPattern.findAll(beforeId).lastOrNull()
return lastHeadingMatch?.range?.first ?: 0
}
/**
* Find the boundaries (start and end positions) of a heading in file content.
*
* @param content The file content to search
* @param nodeId The node ID to find
* @param nodeLevel The level of the node
* @return Pair of (headingStart, nextHeadingStart or null for end of file)
*/
fun findHeadingBoundaries(content: String, nodeId: String, nodeLevel: Int): Pair<Int, Int?>? {
val headingPosition = findHeadingPosition(content, nodeId, nodeLevel) ?: return null
// For level-0 nodes, the content spans the entire file
if (nodeLevel == 0) {
return Pair(0, null)
}
// Find the actual heading level at this position
val line = content.substring(headingPosition).takeWhile { it != '\n' }
val actualLevel = line.takeWhile { it == '*' }.length
// Use findHeadingEndPosition to get position after all children (not just siblings)
val nextHeadingPos = HeadingTextUtils.findHeadingEndPosition(content, headingPosition, actualLevel)
return Pair(headingPosition, nextHeadingPos)
}
/**
* Set a property value in a node's property drawer.
* Creates a property drawer if one doesn't exist.
* Handles both heading-level (level > 0) and file-level (level 0) nodes.
*
* @param nodeId The node ID to modify
* @param key Property key (e.g., "GEO_COORDS", "PINNED")
* @param value Property value
* @return EditResult indicating success or failure
*/
suspend fun setProperty(nodeId: String, key: String, value: String): EditResult {
return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
if (nodeLevel == 0) {
setFileLevelProperty(content, key, value)
} else {
PropertyDrawerUtils.setProperty(content, headingPosition, key, value)
}
}
}
/**
* Set a property on a heading identified by its raw character position
* (rather than by node ID). Used when a heading doesn't have an ID yet.
*
* @param fileUri The file containing the heading
* @param headingPosition Character offset of the heading start
* @param key Property key
* @param value Property value
* @return EditResult indicating success or failure
*/
suspend fun setPropertyByPosition(fileUri: String, headingPosition: Int, key: String, value: String): EditResult {
val content = try {
fileSystem.readFile(fileUri)
} catch (e: Exception) {
return EditResult.Error("Failed to read file: ${e.message}")
}
val newContent = PropertyDrawerUtils.setProperty(content, headingPosition, key, value)
?: return EditResult.Error("Failed to set property at position $headingPosition")
return persistAndReindex(fileUri, newContent)
}
/**
* Remove a property from a node's property drawer.
* Handles both heading-level (level > 0) and file-level (level 0) nodes.
*
* @param nodeId The node ID to modify
* @param key Property key to remove
* @return EditResult indicating success or failure
*/
suspend fun removeProperty(nodeId: String, key: String): EditResult {
return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
if (nodeLevel == 0) {
removeFileLevelProperty(content, key)
} else {
PropertyDrawerUtils.removeProperty(content, headingPosition, key)
}
}
}
/**
* Set a property in the file-level property drawer (for level-0 nodes).
* The file-level property drawer is at the very start of the file.
*/
private fun setFileLevelProperty(content: String, key: String, value: String): String {
val propertyLine = ":$key: $value"
val trimmed = content.trimStart()
if (trimmed.startsWith(":PROPERTIES:")) {
// Find the existing property drawer
val endIdx = content.indexOf(":END:")
if (endIdx == -1) return content
val drawerContent = content.substring(0, endIdx)
// Check if this property already exists
val propertyPattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
return if (propertyPattern.containsMatchIn(drawerContent)) {
// Update existing property
val updatePattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
content.replace(updatePattern) { match ->
// Only replace if it's within the drawer (before :END:)
if (match.range.first < endIdx) propertyLine else match.value
}
} else {
// Insert new property before :END:
content.substring(0, endIdx) + "$propertyLine\n" + content.substring(endIdx)
}
} else {
// No property drawer - create one at the start of the file
return ":PROPERTIES:\n$propertyLine\n:END:\n$content"
}
}
/**
* Remove a property from the file-level property drawer (for level-0 nodes).
*/
private fun removeFileLevelProperty(content: String, key: String): String {
val trimmed = content.trimStart()
if (!trimmed.startsWith(":PROPERTIES:")) {
// No property drawer, nothing to remove
return content
}
val endIdx = content.indexOf(":END:")
if (endIdx == -1) return content
val drawerContent = content.substring(0, endIdx)
val propertyPattern = Regex("^:${Regex.escape(key)}:.*\\n?", RegexOption.MULTILINE)
if (!propertyPattern.containsMatchIn(drawerContent)) {
// Property doesn't exist
return content
}
// Count properties in the drawer (excluding :PROPERTIES: and :END: lines)
val propLines = drawerContent.lines().filter { line ->
val t = line.trim()
t.startsWith(":") && !t.startsWith(":PROPERTIES:") && t != ":"
}
if (propLines.size <= 1) {
// This is the only property (besides maybe :ID:) - but we shouldn't
// remove the entire drawer if :ID: is still there.
// Just remove this property line.
val newDrawer = drawerContent.replace(propertyPattern, "")
// Check if drawer would be empty (only :PROPERTIES: and whitespace left)
val remainingProps = newDrawer.lines().filter { line ->
val t = line.trim()
t.startsWith(":") && !t.startsWith(":PROPERTIES:") && t != ":"
}
if (remainingProps.isEmpty()) {
// Remove the entire drawer
val fullEndIdx = content.indexOf(":END:") + ":END:".length
val afterDrawer = content.substring(fullEndIdx).trimStart('\n')
return afterDrawer
}
}
// Remove just this property line from the drawer
return content.replace(propertyPattern) { match ->
if (match.range.first < endIdx) "" else match.value
}
}
/**
* Load the full heading content (including heading line, properties drawer, and body).
* This is used when editing an existing node to populate the editor with the current content.
*
* @param nodeId The node ID to load
* @return EditResult containing the full heading content on success, or error on failure
*/
suspend fun loadHeadingContent(nodeId: String): EditResult {
val node = repository.getNodeById(nodeId)
?: return EditResult.Error("Node not found: $nodeId")
val content = try {
fileSystem.readFile(node.file)
} catch (e: Exception) {
return EditResult.Error("Failed to read file: ${e.message}")
}
val boundaries = findHeadingBoundaries(content, nodeId, node.level)
?: return EditResult.Error("Could not find heading position for node: $nodeId")
val (headingPosition, nextHeadingPosition) = boundaries
val fullContent = HeadingTextUtils.extractFullHeading(content, headingPosition, node.level)
return EditResult.Success(fullContent)
}
/**
* Replace the body text of a heading, preserving the heading line and properties drawer.
*
* @param nodeId The node ID to modify
* @param newBody New body text to insert
* @return EditResult indicating success or failure
*/
suspend fun replaceHeadingBody(nodeId: String, newBody: String): EditResult {
val node = repository.getNodeById(nodeId)
?: return EditResult.Error("Node not found: $nodeId")
println("[$TAG] replaceHeadingBody: nodeId=$nodeId, node.file=${node.file}, node.level=${node.level}, newBody.length=${newBody.length}")
val content = try {
fileSystem.readFile(node.file)
} catch (e: Exception) {
return EditResult.Error("Failed to read file: ${e.message}")
}
println("[$TAG] replaceHeadingBody: read content.length=${content.length}")
val boundaries = findHeadingBoundaries(content, nodeId, node.level)
?: return EditResult.Error("Could not find heading position for node: $nodeId")
val (headingPosition, nextHeadingPosition) = boundaries
println("[$TAG] replaceHeadingBody: boundaries: headingPosition=$headingPosition, nextHeadingPosition=$nextHeadingPosition")
println("[$TAG] replaceHeadingBody: original content[0..500]: ${content.substring(0, minOf(500, content.length))}")
println("[$TAG] replaceHeadingBody: newBody.length=${newBody.length}, newBody[0..200]: ${newBody.substring(0, minOf(200, newBody.length))}")
val newContent = HeadingTextUtils.replaceHeadingBody(
content, headingPosition, nextHeadingPosition, newBody
)
println("[$TAG] replaceHeadingBody: newContent.length=${newContent.length}, newContent[0..500]: ${newContent.substring(0, minOf(500, newContent.length))}")
return persistAndReindex(node.file, newContent)
}
/**
* Extract the body text of a heading, excluding the heading line and properties drawer.
*
* @param content File content
* @param headingPosition Character offset of the heading start
* @param nextHeadingPosition Character offset of the next heading, or null for end of file
* @return The body text
*/
fun extractHeadingBody(content: String, headingPosition: Int, nextHeadingPosition: Int?): String {
return HeadingTextUtils.extractHeadingBody(content, headingPosition, nextHeadingPosition)
}
/**
* Refile a heading from its current location to under a target node.
* Handles both same-file and cross-file refile operations.
*
* @param sourceNodeId The node ID to refile
* @param targetNodeId The target node to refile under
* @return EditResult indicating success or failure
*/
suspend fun refileHeading(sourceNodeId: String, targetNodeId: String): EditResult {
// Cannot refile to self
if (sourceNodeId == targetNodeId) {
return EditResult.Error("Cannot refile to the same node")
}
val sourceNode = repository.getNodeById(sourceNodeId)
?: return EditResult.Error("Source node not found: $sourceNodeId")
// Cannot refile level-0 nodes (file-level)
if (sourceNode.level == 0) {
return EditResult.Error("Cannot refile file-level nodes")
}
val targetNode = repository.getNodeById(targetNodeId)
?: return EditResult.Error("Target node not found: $targetNodeId")
val sourceContent = try {
fileSystem.readFile(sourceNode.file)
} catch (e: Exception) {
return EditResult.Error("Failed to read source file: ${e.message}")
}
val sourceBoundaries = findHeadingBoundaries(sourceContent, sourceNodeId, sourceNode.level)
?: return EditResult.Error("Could not find source heading position")
val (headingPosition, _) = sourceBoundaries
// Extract the heading content
val headingContent = HeadingTextUtils.extractFullHeading(
sourceContent, headingPosition, sourceNode.level
)
// Calculate target level: one deeper than target, or level 1 if target is file-level
val newLevel = if (targetNode.level == 0) 1 else targetNode.level + 1
// Adjust heading levels in the extracted content
val adjustedContent = HeadingTextUtils.adjustHeadingLevels(headingContent, newLevel)
val sourceUri = sourceNode.file
val targetUri = targetNode.file
val isSameFile = sourceUri == targetUri
try {
if (isSameFile) {
// Same file refile
val targetPosition = findHeadingPosition(sourceContent, targetNodeId, targetNode.level)
?: return EditResult.Error("Could not find target node position in file")
// Remove heading from source location first
var modifiedContent = HeadingTextUtils.removeFullHeading(
sourceContent, headingPosition, sourceNode.level
)
// Adjust target position if it was after the removed heading
val adjustedTargetPosition = if (targetPosition > headingPosition) {
val removedLength = sourceContent.length - modifiedContent.length
maxOf(0, targetPosition - removedLength)
} else {
targetPosition
}
// Insert at new location
modifiedContent = HeadingTextUtils.insertHeadingUnder(
modifiedContent,
adjustedTargetPosition,
targetNode.level,
adjustedContent
)
// Write modified file
fileSystem.writeFile(sourceUri, modifiedContent)
// Re-index (non-fatal)
tryReindex(sourceUri)
return EditResult.Success(modifiedContent)
} else {
// Different file refile
val targetContent = try {
fileSystem.readFile(targetUri)
} catch (e: Exception) {
return EditResult.Error("Failed to read target file: ${e.message}")
}
// Get target position in target file
val targetPosition = findHeadingPosition(targetContent, targetNodeId, targetNode.level)
?: 0 // Default to start if we can't find it (for file-level targets)
// Remove heading from source file
val modifiedSourceContent = HeadingTextUtils.removeFullHeading(
sourceContent, headingPosition, sourceNode.level
)
// Insert heading into target file
val modifiedTargetContent = HeadingTextUtils.insertHeadingUnder(
targetContent,
targetPosition,
targetNode.level,
adjustedContent
)
// Write both files
fileSystem.writeFile(sourceUri, modifiedSourceContent)
fileSystem.writeFile(targetUri, modifiedTargetContent)
// Re-index both files (non-fatal)
tryReindex(sourceUri)
tryReindex(targetUri)
return EditResult.Success(modifiedTargetContent)
}
} catch (e: Exception) {
return EditResult.Error("Failed to refile: ${e.message}")
}
}
/**
* Internal helper that handles the common pattern of:
* 1. Look up node
* 2. Read file
* 3. Find heading position
* 4. Apply transformation
* 5. Write file
* 6. Re-index
*
* @param nodeId The node ID to modify
* @param transform Function that transforms file content given (content, headingPosition, nodeLevel)
* @return EditResult indicating success or failure
*/
private suspend fun modifyAndPersist(
nodeId: String,
transform: (content: String, headingPosition: Int, nodeLevel: Int) -> String?
): EditResult {
val node = repository.getNodeById(nodeId)
?: return EditResult.Error("Node not found: $nodeId")
val content = try {
fileSystem.readFile(node.file)
} catch (e: Exception) {
return EditResult.Error("Failed to read file: ${e.message}")
}
val position = findHeadingPosition(content, nodeId, node.level)
?: return EditResult.Error("Could not find heading position for node: $nodeId")
val newContent = transform(content, position, node.level)
?: return EditResult.Error("Transform failed")
return persistAndReindex(node.file, newContent)
}
/**
* Internal helper for position-based edits — skips the repository lookup.
* Used by updateTodoStateByPosition / updatePlanningInfoByPosition for
* headings that may not have an :ID: (and thus no OrgNode in the DB).
*
* @param file File path to edit
* @param position Character offset of the heading start
* @param transform Function that transforms file content given (content, headingPosition)
* @return EditResult indicating success or failure
*/
private suspend fun modifyAndPersistByPosition(
file: String,
position: Int,
transform: (content: String, headingPosition: Int) -> String?
): EditResult {
val content = try {
fileSystem.readFile(file)
} catch (e: Exception) {
return EditResult.Error("Failed to read file: ${e.message}")
}
val newContent = transform(content, position)
?: return EditResult.Error("Transform failed")
return persistAndReindex(file, newContent)
}
/**
* Write content to file and trigger re-indexing.
*/
private suspend fun persistAndReindex(filePath: String, content: String): EditResult {
println("[$TAG] persistAndReindex: filePath=$filePath, content.length=${content.length}")
try {
fileSystem.writeFile(filePath, content)
println("[$TAG] persistAndReindex: file written successfully")
} catch (e: Exception) {
return EditResult.Error("Failed to write file: ${e.message}")
}
// Re-indexing is non-fatal - write succeeded even if re-index fails
tryReindex(filePath)
return EditResult.Success(content)
}
/**
* Attempt to re-index a file. Failures are logged but don't affect the operation result.
*/
private suspend fun tryReindex(filePath: String) {
println("[$TAG] tryReindex: filePath=$filePath")
try {
withContext(Dispatchers.Default) {
indexingService.indexFile(filePath)
}
println("[$TAG] tryReindex: indexFile completed")
} catch (e: Exception) {
println("[$TAG] tryReindex: exception during indexFile: ${e.message}")
}
}
/**
* Append a heading to the end of a file.
* Used for capture operations where we're adding new entries to a daily file.
*
* @param filePath The file path to append to
* @param headingContent The heading content to append (full heading with properties)
* @return EditResult indicating success or failure
*/
suspend fun appendHeading(filePath: String, headingContent: String): EditResult {
val content = try {
fileSystem.readFile(filePath)
} catch (e: Exception) {
return EditResult.Error("Failed to read file: ${e.message}")
}
// Append the heading content
val trimmedContent = content.trimEnd('\n')
val newContent = if (trimmedContent.isEmpty()) {
headingContent
} else {
trimmedContent + "\n\n" + headingContent
}
return persistAndReindex(filePath, newContent)
}
/**
* Set REVIEW_DATA for a flashcard position in the node's REVIEW_DATA drawer.
* Creates the REVIEW_DATA drawer if it doesn't exist.
*
* @param nodeId The flashcard node ID
* @param positionName The position name (e.g., "front", "back", "0")
* @param reviewData Review data to store
* @return EditResult indicating success or failure
*/
suspend fun setReviewData(nodeId: String, positionName: String, reviewData: computer.whatthefuck.arcology.flashcard.ReviewData): EditResult {
return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
if (nodeLevel == 0) {
// For file-level nodes, use file-level REVIEW_DATA drawer
ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)
} else {
// For heading-level nodes, use REVIEW_DATA drawer after PROPERTIES
ReviewDataDrawerUtils.setHeadingLevelReviewData(content, headingPosition, reviewData)
}
}
}
/**
* Update a planning line (SCHEDULED, DEADLINE, or CLOSED) for a heading.
* Pass null as [timestampString] to remove the planning line.
*
* @param nodeId The node ID to modify
* @param kind Which planning keyword to update
* @param timestampString Full org timestamp e.g. "<2026-08-15 Sat 14:00 +1w>" or null to remove
* @return EditResult indicating success or failure
*/
suspend fun updatePlanningInfo(
nodeId: String,
kind: PlanningKind,
timestampString: String?
): EditResult {
return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
if (nodeLevel == 0) null else PlanningInfoUtils.updatePlanningLine(content, headingPosition, kind, timestampString)
}
}
/**
* Update the TODO state of a heading identified by file path and byte position.
* Used for headings that may not have an :ID: (non-node headings).
*
* When entering a done state (DONE/CANCELLED/ARCHIVED) on a heading with a
* repeater, this advances the planning timestamp and flips the TODO keyword
* back to an active state (TODO or REPEAT_TO_STATE), mirroring org-mode's
* org-auto-repeat-mode.
*
* @param file File path containing the heading
* @param position Character offset of the heading start
* @param todoState The new TODO state, or null to clear
* @return EditResult indicating success or failure
*/
suspend fun updateTodoStateByPosition(file: String, position: Int, todoState: String?): EditResult {
return modifyAndPersistByPosition(file, position) { content, headingPosition ->
// Determine level by counting stars at the heading position
val line = content.substring(headingPosition).takeWhile { it != '\n' }
val level = line.takeWhile { it == '*' }.length
if (level == 0) {
return@modifyAndPersistByPosition updateTodoStateInHeading(content, headingPosition, todoState)
}
val currentFrom = readCurrentTodoState(content, headingPosition)
val doneStates = setOf("DONE", "CANCELLED", "ARCHIVED")
// If entering a done state, check for repeater reschedule
if (todoState != null && todoState in doneStates && todoState != currentFrom) {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val reschedule = PlanningInfoUtils.computeReschedule(content, headingPosition, today, defaultRepeatToState)
if (reschedule != null) {
var modified = updateTodoStateInHeading(reschedule.newContent, headingPosition, reschedule.newTodoState)
// Log the transition matching Emacs org-log-into-drawer:
// - State "DONE" from "NEXT" [timestamp]
// The user's action (todoState, e.g. DONE) is the toState;
// the previous active state (currentFrom, e.g. NEXT) is the fromState.
modified = appendLogbookEntry(
modified,
headingPosition,
fromState = currentFrom ?: "nil",
toState = todoState
)
return@modifyAndPersistByPosition modified
}
}
// No repeater, or not entering done state: simple rewrite + optional log
var modified = updateTodoStateInHeading(content, headingPosition, todoState)
if (todoState != null && todoState != currentFrom) {
modified = appendLogbookEntry(
modified,
headingPosition,
fromState = currentFrom ?: "nil",
toState = todoState
)
}
modified
}
}
/**
* Update a planning line for a heading identified by file path and byte position.
* Used for headings that may not have an :ID: (non-node headings).
*
* @param file File path containing the heading
* @param position Character offset of the heading start
* @param kind Which planning keyword to update
* @param timestampString Full org timestamp or null to remove
* @return EditResult indicating success or failure
*/
suspend fun updatePlanningInfoByPosition(
file: String,
position: Int,
kind: PlanningKind,
timestampString: String?
): EditResult {
return modifyAndPersistByPosition(file, position) { content, headingPosition ->
PlanningInfoUtils.updatePlanningLine(content, headingPosition, kind, timestampString)
}
}
/**
* Add a tag to a heading.
* Tags are appended to the heading line after the title, separated by colons.
* If the tag already exists, this is a no-op.
*
* @param nodeId The node ID to modify
* @param tag The tag to add (e.g., "suspended")
* @return EditResult indicating success or failure
*/
suspend fun addTag(nodeId: String, tag: String): EditResult {
return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
addTagToHeading(content, headingPosition, tag)
}
}
/**
* Add a tag to a heading in the file content.
* Tags are appended to the heading line after the title, separated by colons.
* If the tag already exists, returns the content unchanged.
*
* @param content File content
* @param headingPosition Character offset of the heading start
* @param tag The tag to add
* @return Modified content with the tag added
*/
private fun addTagToHeading(content: String, headingPosition: Int, tag: String): String {
// Find the end of the heading line
val headingEnd = findHeadingLineEnd(content, headingPosition)
// Get the heading line
val nextNewline = content.indexOf('\n', headingPosition)
val headingLineEnd = if (nextNewline == -1) content.length else nextNewline
val headingLine = content.substring(headingPosition, headingLineEnd)
// Check if tag already exists
val tagPattern = Regex(":${Regex.escape(tag)}:")
if (tagPattern.containsMatchIn(headingLine)) {
return content
}
// Find where to insert the tag (after title, before existing tags if any)
// Heading format: ** Title :tag1:tag2:
// We want to insert before the first :tag: or at the end before newline
// Find the last non-whitespace character before the first tag or end of line
val trimmedHeading = headingLine.trimEnd()
val insertPos = content.lastIndexOf(trimmedHeading.lastOrNull() ?: ' ', headingLineEnd - 1) + 1
// Insert the tag
val newHeadingLine = headingLine.insertAt(trimmedHeading.length, ":${tag}:")
return content.substring(0, headingPosition) + newHeadingLine + content.substring(headingLineEnd)
}
/**
* Insert a string at a specific position in a string.
*/
private fun String.insertAt(position: Int, other: String): String {
return this.substring(0, position) + other + this.substring(position)
}
/**
* Find the end position of a heading line.
*/
private fun findHeadingLineEnd(content: String, headingPosition: Int): Int {
val newlinePos = content.indexOf('\n', headingPosition)
return if (newlinePos == -1) content.length else newlinePos
}
/**
* Remove a tag from a heading.
*
* @param nodeId The node ID to modify
* @param tag The tag to remove
* @return EditResult indicating success or failure
*/
suspend fun removeTag(nodeId: String, tag: String): EditResult {
return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
removeTagFromHeading(content, headingPosition, tag)
}
}
/**
* Remove a tag from a heading in the file content.
*
* @param content File content
* @param headingPosition Character offset of the heading start
* @param tag The tag to remove (without the colons)
* @return Modified content with the tag removed
*/
private fun removeTagFromHeading(content: String, headingPosition: Int, tag: String): String {
// Find the heading line
val nextNewline = content.indexOf('\n', headingPosition)
val headingLineEnd = if (nextNewline == -1) content.length else nextNewline
val headingLine = content.substring(headingPosition, headingLineEnd)
// Check if tag exists
val tagPattern = Regex(":${Regex.escape(tag)}:")
if (!tagPattern.containsMatchIn(headingLine)) {
return content
}
// Find and remove the tag
val tagToFind = ":${tag}:"
val tagIndex = headingLine.indexOf(tagToFind)
if (tagIndex == -1) {
return content
}
// Remove the tag
val newHeadingLine = headingLine.removeRange(tagIndex, tagIndex + tagToFind.length)
// Check if there are remaining tags (pattern :word: or word: when after tag removal)
// After removing :tag1: from :tag1:tag2:, we get "tag2:" - still needs to be a tag
val remainingTagPattern = Regex("(?::[a-zA-Z][a-zA-Z0-9_-]*:)|(?:[a-zA-Z][a-zA-Z0-9_-]*:)$")
val hasRemainingTags = remainingTagPattern.containsMatchIn(newHeadingLine)
val cleanedHeading = if (hasRemainingTags) {
// Clean up the tag section
// After removing a tag, we might have "tag2:" instead of ":tag2:"
// Fix by ensuring remaining tags have proper format
// First replace any "::" with ":"
var cleaned = newHeadingLine.replace("::", ":")
// Find where tags start - look for pattern like " :word" or at end of title
val tagSectionMatch = Regex("\\s+(:?[a-zA-Z][a-zA-Z0-9_-]*:)+$").find(cleaned)
if (tagSectionMatch != null) {
val tagSection = tagSectionMatch.value
val beforeTags = cleaned.substring(0, tagSectionMatch.range.first)
// Ensure all tags start with : and end with :
// Handle case like "tag2:" -> ":tag2:"
val fixedTags = tagSection.trim()
.split(" ")
.filter { it.isNotEmpty() }
.joinToString(" ") { tagPart ->
if (tagPart.startsWith(":") && tagPart.endsWith(":")) {
tagPart
} else if (tagPart.startsWith(":")) {
tagPart + ":"
} else if (tagPart.endsWith(":")) {
":$tagPart"
} else {
":$tagPart:"
}
}
val finalTags = if (fixedTags.isEmpty()) "" else " $fixedTags"
beforeTags.trimEnd() + finalTags
} else {
cleaned
}
} else {
// No remaining tags, just trim trailing colons and whitespace
newHeadingLine.trimEnd(':', ' ')
}
return content.substring(0, headingPosition) + cleanedHeading + content.substring(headingLineEnd)
}
/**
* Add a file-level tag (filetag) to an org file's =#+FILETAGS:= line.
* If no =#+FILETAGS:= line exists, one is inserted after the =#+TITLE:= line
* (or at the top of the file if no title). If the tag is already present,
* this is a no-op.
*
* @param file File path to edit
* @param tag The tag to add (e.g., "CLOSED")
* @return EditResult indicating success or failure
*/
suspend fun addFileTag(file: String, tag: String): EditResult {
return modifyAndPersistByPosition(file, 0) { content, headingPosition ->
addFileTagToContent(content, tag)
}
}
internal fun addFileTagToContent(content: String, tag: String): String {
// Match the entire #+FILETAGS: line, capturing everything after the keyword.
// Filetags format: #+FILETAGS: :tag1:tag2:tag3:
val filetagsPattern = Regex("(?im)^#\\+FILETAGS:\\s*(.*)$")
val match = filetagsPattern.find(content)
if (match != null) {
val fullLine = match.value
val existingTags = match.groupValues[1].trim() // e.g. ":tag1:tag2:" or ""
// Check if tag already present
val tagPattern = Regex(":${Regex.escape(tag)}:")
if (tagPattern.containsMatchIn(existingTags)) {
return content // already present
}
// Append the tag
val newTags = if (existingTags.isEmpty()) {
":${tag}:"
} else {
// existingTags is ":tag1:tag2:", insert :tag: before trailing :
existingTags.dropLast(1) + ":" + tag + ":"
}
val newLine = "#+FILETAGS: $newTags"
return content.substring(0, match.range.first) + newLine + content.substring(match.range.last + 1)
}
// No #+FILETAGS: line — insert one after #+TITLE: (or at top)
val titlePattern = Regex("(?im)^#\\+TITLE:.*$")
val titleMatch = titlePattern.find(content)
return if (titleMatch != null) {
// Insert on a new line after the title line.
// titleMatch.range.last is the last char of the title line (no newline).
// We need to insert "\n#+FILETAGS: :tag:" after it.
val insertPos = titleMatch.range.last + 1
val insertLine = "\n#+FILETAGS: :${tag}:"
content.substring(0, insertPos) + insertLine + content.substring(insertPos)
} else {
// No title — insert at the very top
val insertLine = "#+FILETAGS: :${tag}:\n"
content.substring(0, 0) + insertLine + content.substring(0)
}
}
/**
* Remove a file-level tag (filetag) from an org file's =#+FILETAGS:= line.
* If the line becomes empty (just `#+FILETAGS:`), it is removed entirely.
*
* @param file File path to edit
* @param tag The tag to remove
* @return EditResult indicating success or failure
*/
suspend fun removeFileTag(file: String, tag: String): EditResult {
return modifyAndPersistByPosition(file, 0) { content, headingPosition ->
removeFileTagFromContent(content, tag)
}
}
internal fun removeFileTagFromContent(content: String, tag: String): String {
val filetagsPattern = Regex("(?im)^#\\+FILETAGS:\\s*(.*)$")
val match = filetagsPattern.find(content) ?: return content
val existingTags = match.groupValues[1].trim()
val tagPattern = Regex(":${Regex.escape(tag)}:")
if (!tagPattern.containsMatchIn(existingTags)) {
return content
}
val newTags = existingTags.replace(":${tag}:", ":")
// If only ":" remains, remove the whole line
return if (newTags == ":" || newTags.isBlank()) {
// Remove the line and its trailing newline
val removeEnd = if (match.range.last + 1 < content.length && content[match.range.last + 1] == '\n') {
match.range.last + 2
} else {
match.range.last + 1
}
content.substring(0, match.range.first) + content.substring(removeEnd)
} else {
val newLine = "#+FILETAGS: $newTags"
content.substring(0, match.range.first) + newLine + content.substring(match.range.last + 1)
}
}
@OptIn(ExperimentalTime::class)
private fun appendLogbookEntry(
content: String,
headingPosition: Int,
fromState: String,
toState: String
): String {
val now = Clock.System.now()
val today = now.toLocalDateTime(TimeZone.currentSystemDefault())
val weekdays = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
val dayName = weekdays[today.dayOfWeek.ordinal]
val dateStr = "${today.date} $dayName ${today.hour.toString().padStart(2, '0')}:${today.minute.toString().padStart(2, '0')}"
val logbookEntry = "- State \"$toState\" from \"$fromState\" [$dateStr]"
val headingEnd = findNextHeadingOrFileEnd(content, headingPosition)
val section = content.substring(headingPosition, headingEnd)
val logbookPattern = Regex(":LOGBOOK:")
return if (logbookPattern.containsMatchIn(section)) {
val logbookStart = section.indexOf(":LOGBOOK:")
val afterStart = section.substring(logbookStart)
val logbookLineEnd = afterStart.indexOf('\n').let { if (it == -1) afterStart.length else it }
val insertPos = headingPosition + logbookStart + logbookLineEnd + 1
content.substring(0, insertPos) + "$logbookEntry\n" + content.substring(insertPos)
} else {
val updatedSection = section.trimEnd('\n') + "\n\n:LOGBOOK:\n $logbookEntry\n:END:\n"
content.substring(0, headingPosition) + updatedSection + content.substring(headingEnd)
}
}
private fun findNextHeadingOrFileEnd(content: String, headingPosition: Int): Int {
val line = content.substring(headingPosition).takeWhile { it != '\n' }
val level = line.takeWhile { it == '*' }.length
if (level == 0) return content.length
val pattern = Regex("^\\*{1,$level} ", RegexOption.MULTILINE)
val nextMatch = pattern.find(content, headingPosition + 1)
return nextMatch?.range?.first ?: content.length
}
}<<ode-preamble>>
<<ode-todo-state>>
<<ode-heading-finder>>
<<ode-property-drawer>>
<<ode-body-editing>>
<<ode-refile>>
<<ode-persistence>>
<<ode-capture>>
<<ode-review-data>>
<<ode-planning-info>>
<<ode-position-based>>
<<ode-tags>>
<<ode-logbook>>Heading Text Utilities
HeadingTextUtils is a set of low-level operations on raw heading text: body extraction, body replacement, heading removal, level adjustment, and insertion. It is used by both OrgDocumentEditor and NodeContentParser.
package computer.whatthefuck.arcology.editor
import xyz.lepisma.orgmode.OrgChunk
import xyz.lepisma.orgmode.core.ParsingResult
import xyz.lepisma.orgmode.core.zeroOrMore
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.lexer.Token
import xyz.lepisma.orgmode.parseChunk
/**
* Utilities for extracting and replacing heading body text within org files.
* Works with raw file content and heading positions (character offsets).
*/
object HeadingTextUtils {
/**
* Extract the body text of a heading, excluding the heading line itself
* and the properties drawer.
*
* @param fileContent Full file content
* @param headingPosition Character offset of the heading start (the `*` character)
* @param nextHeadingPosition Character offset of the next sibling/parent heading, or null for end of file
* @return The body text between properties drawer end and next heading
*/
fun extractHeadingBody(
fileContent: String,
headingPosition: Int,
nextHeadingPosition: Int?
): String {
val endPos = nextHeadingPosition ?: fileContent.length
val section = fileContent.substring(headingPosition, endPos)
// Find end of heading line
val headingLineEnd = section.indexOf('\n')
if (headingLineEnd == -1) return ""
val afterHeading = headingLineEnd + 1
// Skip properties drawer if present
val bodyStart = skipPropertiesDrawer(section, afterHeading)
val body = section.substring(bodyStart).trimEnd('\n')
// Strip REVIEW_DATA drawers from the body
return stripReviewDataDrawers(body)
}
/**
* Extract the body content from a level-0 (file-level) section.
* Level-0 sections start with :PROPERTIES: drawer and #+title: directive,
* followed by body content that may include sub-headings.
*
* @param section The section content (from headingPosition to endPos)
* @param headingPosition The start position within the section (usually 0 for level-0)
* @return Pair of (bodyEndPosition, childrenContent) where bodyEndPosition is the position
* to insert the new body, and childrenContent is any sub-headings to preserve
*/
private fun extractLevel0Body(
section: String,
headingPosition: Int
): Pair<Int, String> {
var pos = headingPosition
// Skip :PROPERTIES: drawer if present
if (section.substring(pos).trimStart().startsWith(":PROPERTIES:")) {
val drawerEnd = section.indexOf(":END:", pos)
if (drawerEnd == -1) return Pair(headingPosition, "")
pos = drawerEnd + ":END:".length
// Skip newline after :END:
if (pos < section.length && section[pos] == '\n') {
pos++
}
}
// Skip #+title: directive
val remaining = section.substring(pos).trimStart()
if (remaining.startsWith("#+title:")) {
val titleEnd = remaining.indexOf('\n')
if (titleEnd != -1) {
pos += titleEnd + 1
} else {
pos = section.length
}
}
// Skip empty lines after title
val afterTitle = section.substring(pos)
var emptyLinesEnd = 0
for (i in afterTitle.indices) {
if (afterTitle[i] == '\n') {
emptyLinesEnd = i + 1
} else if (afterTitle[i] == ' ' || afterTitle[i] == '\t') {
// continue through whitespace
} else {
break
}
}
pos += emptyLinesEnd
// Find sub-headings (lines starting with *) to preserve
val remainingContent = section.substring(pos)
val subHeadingPattern = Regex("^\\*+ ", RegexOption.MULTILINE)
val subHeadingMatch = subHeadingPattern.find(remainingContent)
val childrenContent = if (subHeadingMatch != null) {
// Sub-heading found, preserve it and all following content
val childStart = subHeadingMatch.range.first
section.substring(pos + childStart)
} else {
// No sub-headings
""
}
return Pair(pos, childrenContent)
}
/**
* Replace the body text of a heading, preserving the heading line and properties drawer.
*
* @param fileContent Full file content
* @param headingPosition Character offset of the heading start
* @param nextHeadingPosition Character offset of the next heading, or null for end of file
* @param newBody New body text to insert
* @return Modified file content
*/
fun replaceHeadingBody(
fileContent: String,
headingPosition: Int,
nextHeadingPosition: Int?,
newBody: String
): String {
val endPos = nextHeadingPosition ?: fileContent.length
val section = fileContent.substring(headingPosition, endPos)
// Check if this is a level-0 (file-level) node - content starts with :PROPERTIES: or #+title:
val sectionTrimmed = section.trimStart()
val isLevel0 = sectionTrimmed.startsWith(":PROPERTIES:") || sectionTrimmed.startsWith("#+title:")
if (isLevel0) {
// For level-0 nodes, replace the entire body after properties/title with new content
// The user's newBody contains the complete desired content including any sub-headings
// We don't preserve children because newBody is the complete replacement
// Find the position to insert the body (after properties and title)
val (bodyInsertPos, _) = extractLevel0Body(section, 0)
val bodyEndPos = section.length
// The body to replace is from bodyInsertPos to end of section
val prefix = fileContent.substring(0, headingPosition)
val headingAndProps = section.substring(0, bodyInsertPos)
val suffix = fileContent.substring(endPos)
// Normalize new body content
val normalizedBody = when {
newBody.isEmpty() -> ""
else -> newBody.trimEnd() + "\n"
}
return prefix + headingAndProps + normalizedBody + suffix
}
// Original logic for regular headings
// Find end of heading line
val headingLineEnd = section.indexOf('\n')
if (headingLineEnd == -1) {
// Heading with no content - append body
return fileContent.substring(0, headingPosition + section.length) +
"\n" + newBody + "\n" +
fileContent.substring(endPos)
}
val afterHeading = headingLineEnd + 1
val bodyStart = skipPropertiesDrawer(section, afterHeading)
// Reconstruct: before-heading + heading-line + props + new-body + suffix
// NOTE: newBody is the complete replacement — it already contains any child headings
// from the user's text field. We do NOT preserve children from the original file.
val prefix = fileContent.substring(0, headingPosition)
val headingAndProps = section.substring(0, bodyStart)
val suffix = fileContent.substring(endPos)
val normalizedBody = when {
newBody.isEmpty() -> "\n"
else -> newBody.trimEnd() + "\n"
}
return prefix + headingAndProps + normalizedBody + suffix
}
/**
* Find the next heading at the same or higher level after the given position.
*
* @param fileContent Full file content
* @param headingPosition Starting heading's character offset
* @param headingLevel The level (number of `*`) of the current heading
* @return Character offset of the next same-or-higher-level heading, or null
*/
fun findNextHeadingPosition(
fileContent: String,
headingPosition: Int,
headingLevel: Int
): Int? {
// Search line by line after the heading line
val firstNewline = fileContent.indexOf('\n', headingPosition)
if (firstNewline == -1) return null
var pos = firstNewline + 1
while (pos < fileContent.length) {
val lineEnd = fileContent.indexOf('\n', pos).let { if (it == -1) fileContent.length else it }
val line = fileContent.substring(pos, lineEnd)
// Check if this line is a heading at same or higher level
// Org-mode headings: starts with *, followed by space (not bold markup like *text*)
if (line.isNotEmpty() && line[0] == '*') {
val stars = line.takeWhile { it == '*' }.length
// After the stars, there must be a space for it to be a heading
if (stars < line.length && line[stars] == ' ') {
if (stars in 1..headingLevel) {
return pos
}
}
}
pos = lineEnd + 1
}
return null
}
/**
* Find the end position of a heading's entire section including all children.
* This is used for body replacement to ensure children are preserved.
*
* @param fileContent Full file content
* @param headingPosition Starting heading's character offset
* @param headingLevel The level (number of `*`) of the current heading
* @return Character offset after the heading's content (before next sibling or end of file)
*/
fun findHeadingEndPosition(
fileContent: String,
headingPosition: Int,
headingLevel: Int
): Int? {
// First try to find next sibling at same or higher level
val nextSiblingPos = findNextHeadingPosition(fileContent, headingPosition, headingLevel)
if (nextSiblingPos != null) {
return nextSiblingPos
}
// No sibling found - find position after all children
// This means we need to find the first heading with level < headingLevel after our position
var pos = fileContent.indexOf('\n', headingPosition)
if (pos == -1) return null
pos++ // Move past newline
while (pos < fileContent.length) {
val lineEnd = fileContent.indexOf('\n', pos).let { if (it == -1) fileContent.length else it }
val line = fileContent.substring(pos, lineEnd)
// Check if this is a heading at a higher level (less stars)
// Org-mode headings: starts with *, followed by space (not bold markup like *text*)
if (line.isNotEmpty() && line[0] == '*') {
val stars = line.takeWhile { it == '*' }.length
// After the stars, there must be a space for it to be a heading
if (stars < line.length && line[stars] == ' ') {
if (stars < headingLevel) {
// Found a heading at a higher level - this marks the end of our section
return pos
}
}
}
pos = lineEnd + 1
}
// End of file
return fileContent.length
}
private fun skipPropertiesDrawer(section: String, startOffset: Int): Int {
// Look for :PROPERTIES: at startOffset
val remaining = section.substring(startOffset).trimStart()
if (!remaining.startsWith(":PROPERTIES:")) {
return startOffset
}
val endMarker = ":END:"
val endIdx = section.indexOf(endMarker, startOffset)
if (endIdx == -1) return startOffset
val afterEnd = endIdx + endMarker.length
// Skip the newline after :END:
return if (afterEnd < section.length && section[afterEnd] == '\n') {
afterEnd + 1
} else {
afterEnd
}
}
/**
* Strip :REVIEW_DATA: drawers from body content using the org parser.
* These contain flashcard review metadata and shouldn't be shown to users.
*/
fun stripReviewDataDrawers(body: String): String {
if (body.isBlank()) return body
return try {
// Tokenize and parse the body content
// Guard: OrgLexer crashes on empty input (see AGENTS.md)
val tokens = if (body.isEmpty()) emptyList() else OrgLexer(body).tokenize()
val parser = zeroOrMore(parseChunk)
// Start at position 1 to skip SOF (Start Of File) token
val startPos = if (tokens.isNotEmpty() && tokens[0] is Token.SOF) 1 else 0
val result = parser.invoke(tokens, startPos)
val chunks = when (result) {
is ParsingResult.Success -> result.output
is ParsingResult.Failure -> return body // Fall back to original on parse error
}
// Find all REVIEW_DATA drawer chunks and collect their ranges
// Note: token.range is Pair<Int, Int> where second is EXCLUSIVE
val rangesToRemove = mutableListOf<Pair<Int, Int>>()
for (chunk in chunks) {
if (chunk is OrgChunk.OrgReviewDataDrawer) {
// Get the range from the first to last token
if (chunk.tokens.isNotEmpty()) {
val start = chunk.tokens.first().range.first
val end = chunk.tokens.last().range.second // exclusive
rangesToRemove.add(Pair(start, end))
}
}
}
// If no drawers found, return original body
if (rangesToRemove.isEmpty()) return body
// Sort ranges by start position (descending) to remove from end first
rangesToRemove.sortByDescending { it.first }
// Remove each drawer range from the body
var resultStr = body
for ((start, end) in rangesToRemove) {
// Include any trailing newline after the drawer
val endPos = if (end < resultStr.length && resultStr[end] == '\n') {
end + 1
} else {
end
}
resultStr = resultStr.removeRange(start, endPos.coerceAtMost(resultStr.length))
}
resultStr.trim()
} catch (e: Exception) {
// If parsing fails, fall back to regex-based stripping
val reviewDataPattern = Regex(":REVIEW_DATA:[\\s\\S]*?:END:\\s*\n?")
reviewDataPattern.replace(body, "").trim()
}
}
/**
* Extract a heading and all its children (the full subtree).
*
* @param fileContent Full file content
* @param headingPosition Character offset of the heading start
* @param headingLevel Level of the heading (number of `*`)
* @return The full heading text including all child headings
*/
fun extractFullHeading(
fileContent: String,
headingPosition: Int,
headingLevel: Int
): String {
val endPos = findNextHeadingPosition(fileContent, headingPosition, headingLevel)
?: fileContent.length
return fileContent.substring(headingPosition, endPos).trimEnd('\n') + "\n"
}
/**
* Remove a heading and all its children from file content.
*
* @param fileContent Full file content
* @param headingPosition Character offset of the heading start
* @param headingLevel Level of the heading
* @return Modified file content with heading removed
*/
fun removeFullHeading(
fileContent: String,
headingPosition: Int,
headingLevel: Int
): String {
val endPos = findNextHeadingPosition(fileContent, headingPosition, headingLevel)
?: fileContent.length
val before = fileContent.substring(0, headingPosition)
val after = fileContent.substring(endPos)
// Clean up extra blank lines at the join point
val result = before.trimEnd('\n') + (if (after.isNotEmpty()) "\n" + after.trimStart('\n') else "")
return if (result.isEmpty()) "" else result.trimEnd('\n') + "\n"
}
/**
* Adjust all heading levels in content to start at a target level.
* Preserves relative depth of nested headings.
*
* @param headingContent Content containing headings
* @param targetLevel The level the top heading should become
* @return Content with adjusted heading levels
*/
fun adjustHeadingLevels(
headingContent: String,
targetLevel: Int
): String {
if (targetLevel < 1) return headingContent
// Find the minimum heading level in the content
val headingPattern = Regex("^(\\*+) ", RegexOption.MULTILINE)
val matches = headingPattern.findAll(headingContent).toList()
if (matches.isEmpty()) {
return headingContent
}
val minLevel = matches.minOf { it.groupValues[1].length }
val levelDiff = targetLevel - minLevel
if (levelDiff == 0) {
return headingContent
}
// Delegate to offset-based function
return adjustHeadingLevelsByOffset(headingContent, levelDiff)
}
/**
* Adjust all heading levels by a fixed offset.
* Preserves relative depth of nested headings.
* Levels never go below 1.
*
* @param headingContent Content containing headings
* @param offset The amount to adjust each level (positive = deeper, negative = shallower)
* @return Content with adjusted heading levels
*/
fun adjustHeadingLevelsByOffset(
headingContent: String,
offset: Int
): String {
if (offset == 0) return headingContent
val headingPattern = Regex("^(\\*+) ", RegexOption.MULTILINE)
val matches = headingPattern.findAll(headingContent).toList()
if (matches.isEmpty()) {
return headingContent
}
// Adjust each heading line
return headingPattern.replace(headingContent) { match ->
val currentLevel = match.groupValues[1].length
val newLevel = (currentLevel + offset).coerceAtLeast(1)
"*".repeat(newLevel) + " "
}
}
/**
* Insert a heading as the last child of another heading.
* For level-0 targets (file-level), appends at end of file.
*
* @param fileContent Target file content
* @param targetHeadingPosition Position of the target heading (0 for file-level)
* @param targetHeadingLevel Level of the target heading (0 for file-level)
* @param headingContent Content to insert (heading levels should already be adjusted)
* @return Modified file content
*/
fun insertHeadingUnder(
fileContent: String,
targetHeadingPosition: Int,
targetHeadingLevel: Int,
headingContent: String
): String {
// For file-level target (level 0), append at end of file
if (targetHeadingLevel == 0) {
val trimmed = fileContent.trimEnd('\n')
return if (trimmed.isEmpty()) {
headingContent
} else {
trimmed + "\n\n" + headingContent
}
}
// Find insertion point: just before the next same-or-higher-level heading
val insertPos = findNextHeadingPosition(fileContent, targetHeadingPosition, targetHeadingLevel)
?: fileContent.length
val before = fileContent.substring(0, insertPos).trimEnd('\n')
val after = fileContent.substring(insertPos)
return if (after.isEmpty()) {
before + "\n" + headingContent
} else {
before + "\n" + headingContent + after.trimStart('\n')
}
}
}Property Drawer Utilities
PropertyDrawerUtils uses the org-mode-kmp parser to find headings by position, then performs precise string edits on property drawers. It handles create, update, and delete for heading-level properties.
package computer.whatthefuck.arcology.editor
import xyz.lepisma.orgmode.OrgDocument
import xyz.lepisma.orgmode.OrgHeading
import xyz.lepisma.orgmode.OrgParseResult
import xyz.lepisma.orgmode.OrgSection
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.parseWithDetails
/**
* Utilities for manipulating property drawers in org-mode files.
* Uses orgmode-kmp for parsing, then performs precise string operations
* based on token positions.
*/
object PropertyDrawerUtils {
/**
* Set a property value in a heading's property drawer.
* If the property exists, it will be updated. If not, it will be added.
* If no property drawer exists, one will be created.
*
* @param fileContent Full file content
* @param headingPosition Character offset of the heading start (the `*` character)
* @param key Property key (e.g., "GEO_COORDS")
* @param value Property value
* @return Modified file content, or null if the heading couldn't be found
*/
fun setProperty(
fileContent: String,
headingPosition: Int,
key: String,
value: String
): String? {
val tokens = OrgLexer(fileContent).tokenize()
val parseResult = parseWithDetails(tokens)
if (parseResult !is OrgParseResult.Success) {
return null
}
val document = parseResult.document
// Find the heading at the given position
val heading = findHeadingAtPosition(document, headingPosition)
?: return null
val propertyLine = ":$key: $value"
// Check if the heading has a property drawer
val properties = heading.properties
if (properties != null && properties.tokens.isNotEmpty()) {
// Get drawer position from tokens
val drawerTokens = properties.tokens
val drawerStart = drawerTokens.first().range.first
val drawerEnd = drawerTokens.last().range.second
// Check if this property already exists
val existingValue = properties.map[key]
if (existingValue != null) {
// Update existing property
return updateExistingProperty(fileContent, drawerStart, drawerEnd, key, value)
} else {
// Insert new property before :END:
return insertPropertyInDrawer(fileContent, drawerEnd, propertyLine)
}
} else {
// No property drawer - create one after the heading line
return createPropertyDrawer(fileContent, heading, key, value)
}
}
/**
* Find the heading in the document that starts at the given character position.
*/
private fun findHeadingAtPosition(document: OrgDocument, position: Int): OrgHeading? {
// Search in document content sections
for (section in document.content) {
val found = findHeadingInSection(section, position)
if (found != null) return found
}
return null
}
private fun findHeadingInSection(section: OrgSection, position: Int): OrgHeading? {
// Check if this section's heading is at the target position
val headingTokens = section.heading.tokens
if (headingTokens.isNotEmpty()) {
val headingStart = headingTokens.first().range.first
if (headingStart == position) {
return section.heading
}
}
// Search in child sections
for (chunk in section.body) {
if (chunk is OrgSection) {
val found = findHeadingInSection(chunk, position)
if (found != null) return found
}
}
return null
}
/**
* Update an existing property in the drawer.
*/
private fun updateExistingProperty(
fileContent: String,
drawerStart: Int,
drawerEnd: Int,
key: String,
value: String
): String {
val drawer = fileContent.substring(drawerStart, drawerEnd)
// Find and replace the property line
val propertyPattern = Regex("^(\\s*):$key:\\s*.*$", RegexOption.MULTILINE)
val newDrawer = drawer.replace(propertyPattern) { match ->
val indent = match.groupValues[1]
"$indent:$key: $value"
}
return fileContent.substring(0, drawerStart) + newDrawer + fileContent.substring(drawerEnd)
}
/**
* Insert a new property line before :END: in an existing drawer.
*/
private fun insertPropertyInDrawer(
fileContent: String,
drawerEnd: Int,
propertyLine: String
): String {
// Find the :END: marker position
// The drawerEnd is the position after :END:, so we need to find :END: before it
val searchStart = maxOf(0, drawerEnd - 20)
val endSection = fileContent.substring(searchStart, drawerEnd)
val endMarkerPos = endSection.lastIndexOf(":END:")
if (endMarkerPos == -1) {
// Fallback: insert before drawerEnd
return fileContent.substring(0, drawerEnd) + propertyLine + "\n" + fileContent.substring(drawerEnd)
}
val absoluteEndPos = searchStart + endMarkerPos
// Find the indentation of :END:
val lineStart = fileContent.lastIndexOf('\n', absoluteEndPos - 1) + 1
val indent = fileContent.substring(lineStart, absoluteEndPos).takeWhile { it == ' ' || it == '\t' }
// Insert the new property line before :END:
return fileContent.substring(0, absoluteEndPos) +
"$indent$propertyLine\n" +
fileContent.substring(absoluteEndPos)
}
/**
* Create a new property drawer after the heading line.
*/
private fun createPropertyDrawer(
fileContent: String,
heading: OrgHeading,
key: String,
value: String
): String {
// Find the end of the heading line
val headingTokens = heading.tokens
if (headingTokens.isEmpty()) return fileContent
val headingStart = headingTokens.first().range.first
// Find the end of the heading line (after title, tags, etc.)
val headingEnd = findHeadingLineEnd(fileContent, headingStart)
// Check if there's planning info (SCHEDULED, DEADLINE, etc.) that comes before properties
val planningInfo = heading.planningInfo
val insertPosition = if (planningInfo != null && planningInfo.tokens.isNotEmpty()) {
// Insert after planning info
planningInfo.tokens.last().range.second
} else {
headingEnd
}
// Create the property drawer with proper indentation
val drawer = "\n:PROPERTIES:\n:$key: $value\n:END:"
return fileContent.substring(0, insertPosition) +
drawer +
fileContent.substring(insertPosition)
}
/**
* Find the position after the heading line (after the newline).
*/
private fun findHeadingLineEnd(fileContent: String, headingStart: Int): Int {
val newlinePos = fileContent.indexOf('\n', headingStart)
return if (newlinePos == -1) fileContent.length else newlinePos
}
/**
* Remove a property from a heading's property drawer.
* If the property doesn't exist, returns the content unchanged.
* If removing the property leaves an empty drawer, the drawer is also removed.
*
* @param fileContent Full file content
* @param headingPosition Character offset of the heading start (the `*` character)
* @param key Property key to remove
* @return Modified file content, or null if the heading couldn't be found
*/
fun removeProperty(
fileContent: String,
headingPosition: Int,
key: String
): String? {
val tokens = OrgLexer(fileContent).tokenize()
val parseResult = parseWithDetails(tokens)
if (parseResult !is OrgParseResult.Success) {
return null
}
val document = parseResult.document
// Find the heading at the given position
val heading = findHeadingAtPosition(document, headingPosition)
?: return null
// Check if the heading has a property drawer
val properties = heading.properties
if (properties == null || properties.tokens.isEmpty()) {
// No property drawer, nothing to remove
return fileContent
}
// Check if the property exists
if (properties.map[key] == null) {
// Property doesn't exist, return unchanged
return fileContent
}
val drawerTokens = properties.tokens
val drawerStart = drawerTokens.first().range.first
val drawerEnd = drawerTokens.last().range.second
// If this is the only property, remove the entire drawer
if (properties.map.size == 1) {
// Find the start of the line containing :PROPERTIES:
val lineStart = fileContent.lastIndexOf('\n', drawerStart - 1)
val actualStart = if (lineStart == -1) 0 else lineStart
// Find the end of the line containing :END:
val lineEnd = fileContent.indexOf('\n', drawerEnd)
val actualEnd = if (lineEnd == -1) drawerEnd else lineEnd
return fileContent.substring(0, actualStart) + fileContent.substring(actualEnd)
}
// Remove just this property line
val drawer = fileContent.substring(drawerStart, drawerEnd)
val propertyPattern = Regex("^\\s*:$key:\\s*.*\\n?", RegexOption.MULTILINE)
val newDrawer = drawer.replace(propertyPattern, "")
return fileContent.substring(0, drawerStart) + newDrawer + fileContent.substring(drawerEnd)
}
}Review Data Drawer Utilities
ReviewDataDrawerUtils manages REVIEW_DATA drawers — org-mode tables storing flashcard review metadata. It formats rows, creates drawers, updates existing ones, and handles both file-level and heading-level placement.
package computer.whatthefuck.arcology.editor
import computer.whatthefuck.arcology.flashcard.ReviewData
import kotlin.time.Instant
/**
* Utilities for manipulating REVIEW_DATA drawers in org-mode files.
* REVIEW_DATA drawers store spaced repetition metadata for flashcards.
*
* Format:
* :REVIEW_DATA:
* | position | ease | box | interval | due | review_count |
* |----------+------+-----+----------+-----+--------------|
* | front | 2.50 | 1 | 1.0 | 2025-01-15T10:00:00Z | 5 |
* :END:
*/
object ReviewDataDrawerUtils {
/**
* Format a ReviewData object as a table row.
* The due date is formatted as ISO 8601 without milliseconds (org-fc compatible).
* Interval is rounded to 2 decimal places.
*/
fun formatReviewDataRow(reviewData: ReviewData): String {
val dueDateStr = reviewData.dueDate?.let {
it.toString().removeSuffix(".000Z").removeSuffix("Z") + "Z"
} ?: ""
val intervalStr = reviewData.intervalDays?.let { "%.2f".format(it) } ?: ""
return "| ${reviewData.positionName} | ${reviewData.easeFactor ?: ""} | ${reviewData.box ?: ""} | $intervalStr | $dueDateStr |"
}
/**
* Create the table header for REVIEW_DATA.
*/
fun formatTableHeader(): String {
return "| position | ease | box | interval | due |"
}
/**
* Create the table separator line.
*/
fun formatTableSeparator(): String {
return "|----------+------+-----+----------+------|"
}
/**
* Create a new REVIEW_DATA drawer with a single row.
*/
fun createDrawer(reviewData: ReviewData): String {
return ":REVIEW_DATA:\n${formatTableHeader()}\n${formatTableSeparator()}\n${formatReviewDataRow(reviewData)}\n:END:\n"
}
/**
* Set or update REVIEW_DATA in file content for a file-level node.
* Creates or updates the :REVIEW_DATA: drawer at the start of the file.
*
* @param content Full file content
* @param reviewData Review data to add/update
* @return Modified file content
*/
fun setFileLevelReviewData(content: String, reviewData: ReviewData): String {
val trimmed = content.trimStart()
// Look for existing :REVIEW_DATA: drawer
val reviewDataDrawerPattern = Regex(":REVIEW_DATA:\\s*\n", RegexOption.MULTILINE)
val drawerMatch = reviewDataDrawerPattern.find(trimmed)
return if (drawerMatch != null) {
// Found existing REVIEW_DATA drawer - update it
updateExistingDrawer(content, reviewData)
} else {
// No REVIEW_DATA drawer - create one after :PROPERTIES: drawer or at the start
createNewDrawer(content, reviewData)
}
}
/**
* Set or update REVIEW_DATA in file content for a heading-level node.
* Creates or updates the :REVIEW_DATA: drawer after the heading's :PROPERTIES: drawer.
*
* @param content Full file content
* @param headingPosition Character offset of the heading start
* @param reviewData Review data to add/update
* @return Modified file content, or null if heading couldn't be found
*/
fun setHeadingLevelReviewData(
content: String,
headingPosition: Int,
reviewData: ReviewData
): String? {
// Find the end of the heading line
val newlineAfterHeading = content.indexOf('\n', headingPosition)
if (newlineAfterHeading == -1) return null
// Find the :PROPERTIES: drawer end (if any) or use end of heading line
val propertiesEndPattern = Regex(":PROPERTIES:[\\s\\S]*?:END:\\s*\n")
val searchStart = newlineAfterHeading + 1
val searchRegion = content.substring(searchStart, minOf(searchStart + 500, content.length))
val propsMatch = propertiesEndPattern.find(searchRegion)
val insertPosition = if (propsMatch != null) {
// Insert after :PROPERTIES: drawer
searchStart + propsMatch.range.last + 1
} else {
// Insert after heading line
newlineAfterHeading + 1
}
// Check if there's already a :REVIEW_DATA: drawer in this heading's content
val headingEnd = findNextHeadingPosition(content, headingPosition)
val headingContent = content.substring(insertPosition, headingEnd)
val reviewDataDrawerPattern = Regex(":REVIEW_DATA:\\s*\n")
val existingDrawerMatch = reviewDataDrawerPattern.find(headingContent)
return if (existingDrawerMatch != null) {
// Update existing drawer
val drawerStart = insertPosition + existingDrawerMatch.range.first
updateExistingDrawerInRange(content, drawerStart, reviewData)
} else {
// Create new drawer
val drawer = createDrawer(reviewData)
content.substring(0, insertPosition) + drawer + content.substring(insertPosition)
}
}
/**
* Update an existing REVIEW_DATA drawer by adding/updating a row.
*/
private fun updateExistingDrawer(content: String, reviewData: ReviewData): String {
val drawerStartPattern = Regex(":REVIEW_DATA:\\s*\n")
val drawerStartMatch = drawerStartPattern.find(content) ?: return content
val drawerStart = drawerStartMatch.range.first
return updateExistingDrawerInRange(content, drawerStart, reviewData)
}
/**
* Update REVIEW_DATA drawer starting at the given position.
*/
private fun updateExistingDrawerInRange(
content: String,
drawerStart: Int,
reviewData: ReviewData
): String {
// Find :END: after the drawer start
val afterDrawerStart = content.substring(drawerStart)
val endPattern = Regex(":END:\\s*\n?")
val endMatch = endPattern.find(afterDrawerStart) ?: return content
val drawerEnd = drawerStart + endMatch.range.last + 1
val drawerContent = content.substring(drawerStart, drawerEnd)
// Check if position already exists in table
val escapedPosition = Regex.escape(reviewData.positionName)
val positionPattern = Regex("^\\s*\\|\\s*${escapedPosition}\\s*\\|.*$", RegexOption.MULTILINE)
val rowMatch = positionPattern.find(drawerContent)
val newRow = formatReviewDataRow(reviewData)
return if (rowMatch != null) {
// Update existing row
val updatedDrawer = drawerContent.replace(rowMatch.value, newRow)
content.substring(0, drawerStart) + updatedDrawer + content.substring(drawerEnd)
} else {
// Insert new row before :END:
val beforeEnd = content.substring(0, drawerEnd)
val afterEnd = content.substring(drawerEnd)
// Find the last data row (skip header and separator)
val lines = drawerContent.lines()
val lastDataRowIdx = lines.indexOfLast { it.trim().startsWith("|") && !it.trim().startsWith("|-") && !it.contains("position") }
if (lastDataRowIdx >= 0) {
// Insert after the last data row
val insertLineIdx = drawerStart + lines.take(lastDataRowIdx + 1).sumOf { it.length + 1 }
content.substring(0, insertLineIdx) + newRow + "\n" + content.substring(insertLineIdx)
} else {
// Insert after separator
beforeEnd + newRow + "\n" + afterEnd
}
}
}
/**
* Create a new REVIEW_DATA drawer after :PROPERTIES: or at the start.
*/
private fun createNewDrawer(content: String, reviewData: ReviewData): String {
val trimmed = content.trimStart()
val drawer = createDrawer(reviewData)
// Check if file starts with :PROPERTIES:
if (trimmed.startsWith(":PROPERTIES:")) {
// Find end of :PROPERTIES: drawer
val propsEndPattern = Regex(":PROPERTIES:[\\s\\S]*?:END:\\s*\n?")
val propsMatch = propsEndPattern.find(trimmed)
if (propsMatch != null) {
// Insert after :PROPERTIES: drawer
val insertPos = content.indexOf(":END:") + ":END:".length
// Skip trailing newline if present
val actualInsertPos = if (insertPos < content.length && content[insertPos] == '\n') {
insertPos + 1
} else {
insertPos
}
return content.substring(0, actualInsertPos) + drawer + content.substring(actualInsertPos)
}
}
// Insert at the start of file
return drawer + "\n" + content
}
/**
* Find the position of the next heading at same or higher level.
*/
private fun findNextHeadingPosition(content: String, headingPosition: Int): Int {
// Get the heading level
val headingLine = content.substring(headingPosition).lines().firstOrNull() ?: return content.length
val stars = headingLine.takeWhile { it == '*' }.length
// Search for next heading at same or higher level
var pos = headingPosition + headingLine.length + 1
while (pos < content.length) {
val nextNewline = content.indexOf('\n', pos)
val lineEnd = if (nextNewline == -1) content.length else nextNewline
val line = content.substring(pos, lineEnd)
if (line.isNotEmpty() && line[0] == '*') {
val nextStars = line.takeWhile { it == '*' }.length
if (nextStars in 1..stars) {
return pos
}
}
pos = lineEnd + 1
}
return content.length
}
}Planning Info Utilities
PlanningInfoUtils performs raw string surgery on SCHEDULED/DEADLINE/CLOSED planning lines that live between the heading line and the :PROPERTIES: drawer. It also implements repeater rescheduling: when a heading with a repeater enters a done state, the planning timestamp is advanced and the TODO keyword is flipped back to an active state. This mirrors org-mode's org-auto-repeat-mode behavior.
The object is pure — it takes file content and a heading position, returns transformed content. This makes it unit-testable without FileSystemInterface or RoamRepository. It follows the same pattern as PropertyDrawerUtils.
Position of planning lines
The org-mode parser (parser.org parseHeading at OrgSection.kt:225) expects planning info to appear after the heading line and before the :PROPERTIES: drawer:
,* TODO Task
SCHEDULED: <2026-08-15 Sat 14:00 +1w>
:PROPERTIES:
:ID: task-id
:END:All insert/replace operations preserve this ordering.
Repeater semantics
The three repeater types (defined in Repeater.kt) control how the timestamp advances:
| Type | Syntax | Behavior |
|---|---|---|
| EXACT | +1d | Shift from original date. Backlog allowed (new date may be in the past). |
| CUMULATE | ++1d | Shift from today. No backlog. |
| CATCH_UP | .+1d | Shift from original, keep adding until new date >= today. |
When a done state is entered on a heading with a repeater:
The planning timestamp is advanced per the repeater type.
The TODO keyword is flipped back to an active state.
REPEAT_TO_STATEproperty overrides the defaultTODOtarget.A LOGBOOK state-change entry is still recorded (done → reschedule target).
package computer.whatthefuck.arcology.editor
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.plus
import xyz.lepisma.orgmode.OrgDocument
import xyz.lepisma.orgmode.OrgLine
import xyz.lepisma.orgmode.OrgParseResult
import xyz.lepisma.orgmode.OrgSection
import xyz.lepisma.orgmode.Repeater
import xyz.lepisma.orgmode.RepeaterType
import xyz.lepisma.orgmode.TimeUnit
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.parseRepeater
import xyz.lepisma.orgmode.parseWithDetails
import xyz.lepisma.orgmode.plainText
enum class PlanningKind(val keyword: String) {
SCHEDULED("SCHEDULED"),
DEADLINE("DEADLINE"),
CLOSED("CLOSED")
}
data class RescheduleResult(
val newContent: String,
val newTodoState: String,
val rescheduledKind: PlanningKind
)
object PlanningInfoUtils {
private val WEEKDAY_ABBREV = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
/**
* Insert, replace, or remove a planning line for the heading at [headingPosition].
*
* @param content Full file content
* @param headingPosition Character offset of the heading start (the `*` character)
* @param kind Which planning keyword to set
* @param timestampString Full org timestamp e.g. "<2026-08-15 Sat 14:00 +1w>" or null to remove
* @return Modified content, or null if the heading couldn't be found
*/
fun updatePlanningLine(
content: String,
headingPosition: Int,
kind: PlanningKind,
timestampString: String?
): String? {
val headingEnd = findHeadingLineEnd(content, headingPosition)
// Start the planning region after the heading's trailing newline so the
// regex below matches the SCHEDULED/DEADLINE line itself, not the newline
// before it (which would cause the newline to be eaten on replace/remove).
val regionStart = if (headingEnd < content.length && content[headingEnd] == '\n') headingEnd + 1 else headingEnd
// Search for existing planning line between regionStart and :PROPERTIES: or blank line
val planningRegionEnd = findPlanningRegionEnd(content, headingEnd)
val planningRegion = content.substring(regionStart, planningRegionEnd)
val linePattern = Regex("^\\s*${kind.keyword}:\\s+.*$", RegexOption.MULTILINE)
val existingMatch = linePattern.find(planningRegion)
if (timestampString == null) {
// Remove mode
if (existingMatch == null) return content
val absoluteStart = regionStart + existingMatch.range.first
val absoluteEnd = regionStart + existingMatch.range.last + 1
// Also consume the trailing newline
val removeEnd = if (absoluteEnd < content.length && content[absoluteEnd] == '\n') absoluteEnd + 1 else absoluteEnd
return content.substring(0, absoluteStart) + content.substring(removeEnd)
}
val newLine = "${kind.keyword}: $timestampString"
return if (existingMatch != null) {
// Replace existing line
val absoluteStart = regionStart + existingMatch.range.first
val absoluteEnd = regionStart + existingMatch.range.last + 1
content.substring(0, absoluteStart) + newLine + content.substring(absoluteEnd)
} else {
// Insert new line after heading line, before planning region content
content.substring(0, regionStart) + newLine + "\n" + content.substring(regionStart)
}
}
/**
* Read the raw planning line string for [kind] at the heading, or null if not present.
* Returns the full line e.g. "SCHEDULED: <2026-08-15 Sat 14:00 +1w>"
*/
fun readPlanningLine(content: String, headingPosition: Int, kind: PlanningKind): String? {
val headingEnd = findHeadingLineEnd(content, headingPosition)
val regionStart = if (headingEnd < content.length && content[headingEnd] == '\n') headingEnd + 1 else headingEnd
val planningRegionEnd = findPlanningRegionEnd(content, headingEnd)
val planningRegion = content.substring(regionStart, planningRegionEnd)
val linePattern = Regex("^\\s*${kind.keyword}:\\s+.*$", RegexOption.MULTILINE)
return linePattern.find(planningRegion)?.value?.trim()
}
/**
* Compute a reschedule: if the heading at [headingPosition] has a repeater on its
* SCHEDULED or DEADLINE planning line, advance the timestamp and determine the
* new TODO state to flip to.
*
* SCHEDULED takes precedence over DEADLINE for rescheduling.
*
* @param content Full file content
* @param headingPosition Character offset of the heading start
* @param today Current date for CUMULATE/CATCH_UP calculations
* @param defaultRepeatToState TODO state to reset to when no =REPEAT_TO_STATE= property is set (defaults to "TODO")
* @return RescheduleResult with shifted content and new TODO state, or null if no repeater
*/
fun computeReschedule(
content: String,
headingPosition: Int,
today: LocalDate,
defaultRepeatToState: String = "TODO"
): RescheduleResult? {
val tokens = OrgLexer(content).tokenize()
val parseResult = parseWithDetails(tokens)
if (parseResult !is OrgParseResult.Success) return null
val document = parseResult.document
val section = findHeadingAtPosition(document, headingPosition) ?: return null
val planning = section.heading.planningInfo ?: return null
val scheduled = planning.scheduled
val deadline = planning.deadline
val (stamp, kind) = when {
scheduled?.repeater != null -> scheduled to PlanningKind.SCHEDULED
deadline?.repeater != null -> deadline to PlanningKind.DEADLINE
else -> return null
}
val repeater = stamp.parseRepeater() ?: return null
val newDate = advanceDate(stamp.date, repeater, today)
val newTimestampStr = formatTimestamp(
date = newDate,
time = stamp.time?.first,
isActive = stamp.isActive,
repeater = stamp.repeater,
showWeekday = stamp.showWeekDay
)
val newContent = updatePlanningLine(content, headingPosition, kind, newTimestampStr)
?: return null
val repeatToState = section.heading.properties?.map?.get("REPEAT_TO_STATE")?.plainText()
?: defaultRepeatToState
return RescheduleResult(
newContent = newContent,
newTodoState = repeatToState,
rescheduledKind = kind
)
}
/**
* Advance a date according to a repeater, relative to [today].
*/
internal fun advanceDate(original: LocalDate, repeater: Repeater, today: LocalDate): LocalDate {
val shifted = shiftDate(original, repeater.value, repeater.unit)
return when (repeater.type) {
RepeaterType.EXACT -> shifted
RepeaterType.CUMULATE -> shiftDate(today, repeater.value, repeater.unit)
RepeaterType.CATCH_UP -> {
var result = shifted
while (result < today) {
result = shiftDate(result, repeater.value, repeater.unit)
}
result
}
}
}
private fun shiftDate(date: LocalDate, value: Int, unit: TimeUnit): LocalDate {
return when (unit) {
TimeUnit.DAYS -> date.plus(DatePeriod(days = value))
TimeUnit.WEEKS -> date.plus(DatePeriod(days = value * 7))
TimeUnit.MONTHS -> date.plus(DatePeriod(months = value))
TimeUnit.YEARS -> date.plus(DatePeriod(years = value))
TimeUnit.HOURS -> date // Hours don't change the date; handled at time level
}
}
/**
* Format a timestamp string in org-mode syntax.
* e.g. "<2026-08-15 Sat 14:00 +1w>"
*/
fun formatTimestamp(
date: LocalDate,
time: LocalTime?,
isActive: Boolean,
repeater: String?,
showWeekday: Boolean
): String {
val open = if (isActive) '<' else '['
val close = if (isActive) '>' else ']'
val parts = mutableListOf<String>()
parts.add(date.toString())
if (showWeekday) {
parts.add(WEEKDAY_ABBREV[date.dayOfWeek.ordinal])
}
if (time != null) {
parts.add("${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}")
}
if (repeater != null) {
parts.add(repeater)
}
return "$open${parts.joinToString(" ")}$close"
}
private fun findHeadingLineEnd(content: String, headingStart: Int): Int {
val newlinePos = content.indexOf('\n', headingStart)
return if (newlinePos == -1) content.length else newlinePos
}
/**
* Find the end of the planning region: the position of :PROPERTIES: drawer,
* or the first blank line after the heading, or end of file.
*/
private fun findPlanningRegionEnd(content: String, headingEnd: Int): Int {
var pos = headingEnd
// Skip past the newline at headingEnd if present
if (pos < content.length && content[pos] == '\n') pos++
while (pos < content.length) {
val lineEnd = content.indexOf('\n', pos).let { if (it == -1) content.length else it }
val line = content.substring(pos, lineEnd).trim()
if (line.isEmpty()) return pos // blank line ends the region
if (line.startsWith(":PROPERTIES:")) return pos
if (line.startsWith(":LOGBOOK:")) return pos
// Another heading
if (line.isNotEmpty() && line[0] == '*') return pos
pos = lineEnd + 1
}
return content.length
}
private fun findHeadingAtPosition(document: OrgDocument, position: Int): OrgSection? {
for (section in document.content) {
val found = findSectionAt(section, position)
if (found != null) return found
}
return null
}
private fun findSectionAt(section: OrgSection, position: Int): OrgSection? {
val headingTokens = section.heading.tokens
if (headingTokens.isNotEmpty()) {
val headingStart = headingTokens.first().range.first
if (headingStart == position) return section
}
for (chunk in section.body) {
if (chunk is OrgSection) {
val found = findSectionAt(chunk, position)
if (found != null) return found
}
}
return null
}
}Node Content Parser
NodeContentParser is the read counterpart to OrgDocumentEditor (the write interface). It segments a heading's content into navigable pieces — text chunks and collapsible child headings — for "narrowed display" in the mobile UI.
package computer.whatthefuck.arcology.editor
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.OrgNode
import xyz.lepisma.orgmode.*
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.lexer.Token
/**
* Represents one entry in a breadcrumb navigation path.
* Includes both nodes with IDs (navigable) and id-less headings (display-only).
*/
data class BreadcrumbEntry(
val nodeId: String?, // null for id-less headings (not navigable)
val title: String,
val level: Int // 0 = file-level, >0 = heading level
)
/**
* Represents a segment of node content for narrowed display.
* When viewing a node, its content is segmented into:
* - Text segments: Regular content that belongs to this node
* - CollapsedNode segments: Child headings with their own :ID: that are rendered as collapsed cards
*/
sealed class NodeContentSegment {
/**
* Regular text content belonging to the current node.
* This includes paragraphs, lists, blocks, etc. but NOT child headings with IDs.
*/
data class Text(
val content: String,
val startOffset: Int,
val endOffset: Int
) : NodeContentSegment()
/**
* A child heading rendered as a collapsed card.
* If nodeId is non-null, the heading has its own :ID: and can be navigated to.
* If nodeId is null, the heading is just a structural element that can be expanded but not navigated.
*/
data class CollapsedNode(
val nodeId: String?, // null if heading has no :ID: property
val title: String,
val content: String,
val level: Int,
val startOffset: Int,
val endOffset: Int
) : NodeContentSegment()
}
/**
* Parses node content and identifies child headings with their own :ID: properties.
* Used to implement "node narrowing" - showing only owned content with child nodes as collapsed blocks.
*/
class NodeContentParser(private val repository: RoamRepository) {
/**
* Parse node content and identify sections that should be collapsed.
*
* @param fileContent Full content of the org file
* @param headingPosition Character offset where the current heading starts
* @param nextHeadingPosition Character offset of the next same-or-higher-level heading, or null for end of file
* @param currentNodeLevel The level of the current node (number of stars)
* @return List of segments representing the node's content
*/
suspend fun parseNodeContent(
fileContent: String,
headingPosition: Int,
nextHeadingPosition: Int?,
currentNodeLevel: Int
): List<NodeContentSegment> {
val tokens = OrgLexer(fileContent).tokenize()
val parseResult = parseWithDetails(tokens)
if (parseResult is OrgParseResult.Failure) {
// Fallback to minimal implementation if parsing fails
println("W: Failed to parse org file content: ${parseResult.error}");
val endPos = nextHeadingPosition ?: fileContent.length
val content = fileContent.substring(headingPosition, endPos).trimEnd('\n')
return if (content.isNotEmpty()) {
listOf(NodeContentSegment.Text(content, headingPosition, endPos))
} else {
emptyList()
}
}
val doc = (parseResult as OrgParseResult.Success).document
// Find the section corresponding to headingPosition
val targetSection = if (currentNodeLevel == 0) null else findSectionAt(doc.content, headingPosition)
val childSections = if (targetSection == null) {
if (currentNodeLevel == 0) doc.content else emptyList()
} else {
targetSection.body.filterIsInstance<OrgSection>()
}
val segments = mutableListOf<NodeContentSegment>()
val bodyItems = if (targetSection == null) {
// Preamble and Preface as initial text chunks
val preamblePreface = mutableListOf<OrgChunk>()
if (doc.preamble.tokens.isNotEmpty()) {
preamblePreface.add(OrgChunk.OrgParagraph(emptyList(), doc.preamble.tokens))
}
if (doc.preface.tokens.isNotEmpty()) {
preamblePreface.add(OrgChunk.OrgParagraph(emptyList(), doc.preface.tokens))
}
preamblePreface + doc.content
} else {
targetSection.body
}
var currentTextTokens = mutableListOf<Token>()
for (item in bodyItems) {
if (item is OrgSection) {
// Flush text
if (currentTextTokens.isNotEmpty()) {
val text = currentTextTokens.joinToString("") { it.text }
val trimmed = text.trimEnd('\n')
if (trimmed.isNotEmpty()) {
segments.add(NodeContentSegment.Text(
trimmed,
currentTextTokens.first().range.first,
currentTextTokens.last().range.second
))
}
currentTextTokens = mutableListOf<Token>()
}
// Add child node
val id = item.heading.properties?.map?.get("ID")?.plainText()
val title = item.heading.title.plainText()
val bodyContent = item.body.filter { it !is OrgSection }
.joinToString("") { chunk -> chunk.tokens.joinToString("") { it.text } }
.trim()
segments.add(NodeContentSegment.CollapsedNode(
nodeId = id,
title = title,
content = bodyContent,
level = item.heading.level.level,
startOffset = item.heading.tokens.first().range.first,
endOffset = item.tokens.last().range.second
))
} else {
currentTextTokens.addAll(item.tokens)
}
}
// Final flush
if (currentTextTokens.isNotEmpty()) {
val text = currentTextTokens.joinToString("") { it.text }
val trimmed = text.trimEnd('\n')
if (trimmed.isNotEmpty()) {
segments.add(NodeContentSegment.Text(
trimmed,
currentTextTokens.first().range.first,
currentTextTokens.last().range.second
))
}
}
return segments
}
private fun findSectionAt(sections: List<OrgSection>, position: Int): OrgSection? {
for (section in sections) {
val start = section.heading.tokens.first().range.first
val end = section.tokens.last().range.second
if (position in start until end) {
val subSection = findSectionAt(section.body.filterIsInstance<OrgSection>(), position)
return subSection ?: section
}
}
return null
}
/**
* Build breadcrumb entries from the file-level root through heading ancestors to the current node.
* Uses outlinePath to correctly include id-less headings that don't have OrgNode records in the DB.
*
* @return List of BreadcrumbEntry from root to immediate parent (excludes current node)
*/
suspend fun getParentNodes(nodeId: String): List<BreadcrumbEntry> {
val node = repository.getNodeById(nodeId) ?: return emptyList()
val fileNodes = repository.getNodesByFile(node.file).sortedBy { it.position }
val result = mutableListOf<BreadcrumbEntry>()
val fileNode = fileNodes.find { it.level == 0 }
if (fileNode != null) {
result.add(BreadcrumbEntry(
nodeId = fileNode.id,
title = fileNode.title ?: fileNode.file.substringAfterLast("/"),
level = 0
))
}
val headingLevel = node.outlinePath.size
for (prefixSize in 1 until headingLevel) {
val prefix = node.outlinePath.take(prefixSize)
val matchingNode = fileNodes.find { it.outlinePath == prefix }
if (matchingNode != null) {
result.add(BreadcrumbEntry(
nodeId = matchingNode.id,
title = matchingNode.title ?: "Untitled",
level = matchingNode.level
))
} else {
result.add(BreadcrumbEntry(
nodeId = null,
title = prefix.last(),
level = prefixSize
))
}
}
return result
}
}
private data class ChildNodeInfo(
val nodeId: String?,
val title: String,
val content: String,
val level: Int,
val startOffset: Int,
val endOffset: Int
)Tests
HeadingTextUtilsTest
Tests heading level adjustment (adjustHeadingLevelsByOffset), body replacement (replaceHeadingBody), and REVIEW_DATA drawer stripping (stripReviewDataDrawers). Covers edge cases: zero/minimum levels, empty content, TODO states, tags, properties drawers, real-world org-roam examples, and level-0 file-level node body replacement including sub-heading preservation semantics.
package computer.whatthefuck.arcology.editor
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HeadingTextUtilsTest {
// ============ adjustHeadingLevelsByOffset Tests ============
@Test
fun testAdjustHeadingLevelsByOffset_negativeOne() {
val content = """
*** Level 3
Content for level 3.
**** Level 4
Content for level 4.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("** Level 3"), "Level 3 should become level 2")
assertTrue(result.contains("*** Level 4"), "Level 4 should become level 3")
assertTrue(!result.contains("*** Level 3"), "Original level 3 should be gone")
assertTrue(!result.contains("**** Level 4"), "Original level 4 should be gone")
}
@Test
fun testAdjustHeadingLevelsByOffset_negativeTwo() {
val content = """
**** Level 4
Content.
***** Level 5
More content.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -2)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("** Level 4"), "Level 4 should become level 2")
assertTrue(result.contains("*** Level 5"), "Level 5 should become level 3")
}
@Test
fun testAdjustHeadingLevelsByOffset_positiveOne() {
val content = """
* Level 1
Content.
** Level 2
More content.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, 1)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("** Level 1"), "Level 1 should become level 2")
assertTrue(result.contains("*** Level 2"), "Level 2 should become level 3")
}
@Test
fun testAdjustHeadingLevelsByOffset_zero() {
val content = """
* Level 1
** Level 2
*** Level 3
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, 0)
assertEquals(content, result, "Offset 0 should return unchanged content")
}
@Test
fun testAdjustHeadingLevelsByOffset_preservesBodyContent() {
val content = """
** Parent
Some body text here.
*** Child
Child body with *bold* text and [[links]].
More content.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("* Parent"), "Parent should become level 1")
assertTrue(result.contains("** Child"), "Child should become level 2")
assertTrue(result.contains("Some body text here."), "Body text should be preserved")
assertTrue(result.contains("Child body with *bold* text and [[links]]."), "Child body should be preserved")
assertTrue(result.contains("More content."), "Trailing content should be preserved")
}
@Test
fun testAdjustHeadingLevelsByOffset_preservesTODOAndTags() {
val content = """
** TODO Task :work:urgent:
Task description.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("* TODO Task :work:urgent:"), "TODO and tags should be preserved")
assertTrue(result.contains("Task description."), "Body should be preserved")
}
@Test
fun testAdjustHeadingLevelsByOffset_complexHierarchy() {
val content = """
*** Grandparent
Grandparent body.
**** Parent
Parent body.
***** Child
Child body.
****** Grandchild
Grandchild body.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -2)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("* Grandparent"), "Level 3 should become level 1")
assertTrue(result.contains("** Parent"), "Level 4 should become level 2")
assertTrue(result.contains("*** Child"), "Level 5 should become level 3")
assertTrue(result.contains("**** Grandchild"), "Level 6 should become level 4")
}
@Test
fun testAdjustHeadingLevelsByOffset_noHeadings() {
val content = """
Just some text content.
No headings here.
More text.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
assertEquals(content, result, "Content without headings should be unchanged")
}
@Test
fun testAdjustHeadingLevelsByOffset_emptyContent() {
val result = HeadingTextUtils.adjustHeadingLevelsByOffset("", -1)
assertEquals("", result, "Empty content should return empty")
}
@Test
fun testAdjustHeadingLevelsByOffset_minimumLevel() {
// Test that levels don't go below 1
val content = """
* Level 1
Content.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
println("Input:\n$content")
println("Output:\n$result")
// Level 1 with offset -1 should stay at level 1 (minimum)
assertTrue(result.contains("* Level 1"), "Level 1 should stay at level 1 (minimum)")
}
@Test
fun testAdjustHeadingLevelsByOffset_mixedWithMinimum() {
val content = """
* Level 1
Content.
** Level 2
More.
*** Level 3
Even more.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("* Level 1"), "Level 1 should stay at level 1")
assertTrue(result.contains("* Level 2"), "Level 2 should become level 1")
assertTrue(result.contains("** Level 3"), "Level 3 should become level 2")
}
@Test
fun testAdjustHeadingLevelsByOffset_preservesProperties() {
val content = """
** Heading with properties
:PROPERTIES:
:ID: 12345
:CUSTOM: value
:END:
Body content.
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("* Heading with properties"), "Heading level should be adjusted")
assertTrue(result.contains(":PROPERTIES:"), "Properties drawer should be preserved")
assertTrue(result.contains(":ID: 12345"), "ID property should be preserved")
assertTrue(result.contains(":CUSTOM: value"), "Custom property should be preserved")
assertTrue(result.contains("Body content."), "Body should be preserved")
}
@Test
fun testAdjustHeadingLevelsByOffset_realWorldExample() {
// Real-world example: viewing a level 2 node, its children (level 3) should become level 2
val content = """
Japanese tea
*** Reading :fc:
:PROPERTIES:
:ID: 20220921T114332.538092
:FC_CREATED: 2022-09-21T18:43:32Z
:FC_TYPE: normal
:END:
まっちゃ
*** 抹 :fc:
:PROPERTIES:
:ID: 20220921T114335.523098
:END:
rub, paint, erase
""".trimIndent()
val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)
println("Input:\n$content")
println("Output:\n$result")
assertTrue(result.contains("** Reading"), "Level 3 should become level 2")
assertTrue(result.contains("** 抹"), "Level 3 should become level 2")
assertTrue(result.contains(":fc:"), "Tags should be preserved")
assertTrue(result.contains("まっちゃ"), "Body text should be preserved")
assertTrue(!result.contains("*** Reading"), "Original level 3 should be gone")
}
@Test
fun testStripReviewDataDrawers_withSingleDrawer() {
val body = """
Some question text here.
:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:
More content after.
""".trimIndent()
val result = HeadingTextUtils.stripReviewDataDrawers(body)
println("Input:\n$body")
println("Output:\n$result")
assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker")
assertTrue(!result.contains("|position|ease|"), "Should not contain drawer content")
assertTrue(result.contains("Some question text here."), "Should retain question text")
assertTrue(result.contains("More content after."), "Should retain content after drawer")
}
@Test
fun testStripReviewDataDrawers_withMultipleDrawers() {
val body = """
Question text.
:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:
Some middle content.
:REVIEW_DATA:
|position|ease|box|interval|due|
|back|2.5|1|1|2026-03-01|
:END:
Final content.
""".trimIndent()
val result = HeadingTextUtils.stripReviewDataDrawers(body)
println("Input:\n$body")
println("Output:\n$result")
assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain any REVIEW_DATA drawer markers")
assertTrue(result.contains("Question text."), "Should retain question text")
assertTrue(result.contains("Some middle content."), "Should retain middle content")
assertTrue(result.contains("Final content."), "Should retain final content")
}
@Test
fun testStripReviewDataDrawers_noDrawer() {
val body = """
Just some regular content.
No drawer here.
""".trimIndent()
val result = HeadingTextUtils.stripReviewDataDrawers(body)
assertEquals(body.trim(), result, "Should return original content when no drawer present")
}
@Test
fun testStripReviewDataDrawers_emptyBody() {
val result = HeadingTextUtils.stripReviewDataDrawers("")
assertEquals("", result)
}
@Test
fun testStripReviewDataDrawers_blankBody() {
val result = HeadingTextUtils.stripReviewDataDrawers(" \n\n ")
assertEquals("", result.trim())
}
@Test
fun testStripReviewDataDrawers_drawerAtStart() {
val body = """:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:
Content after drawer."""
val result = HeadingTextUtils.stripReviewDataDrawers(body)
println("=== testStripReviewDataDrawers_drawerAtStart ===")
println("Input:\n$body")
println("Output:\n$result")
println("Result contains REVIEW_DATA: ${result.contains(":REVIEW_DATA:")}")
assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker but got: $result")
assertTrue(result.contains("Content after drawer."), "Should retain content after drawer")
}
@Test
fun testStripReviewDataDrawers_drawerAtEnd() {
val body = """Content before drawer.
:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:"""
val result = HeadingTextUtils.stripReviewDataDrawers(body)
println("Input:\n$body")
println("Output:\n$result")
assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker")
assertTrue(result.contains("Content before drawer."), "Should retain content before drawer")
}
@Test
fun testStripReviewDataDrawers_onlyDrawer() {
val body = """:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:"""
val result = HeadingTextUtils.stripReviewDataDrawers(body)
println("Input:\n$body")
println("Output:\n$result")
assertEquals("", result, "Should return empty string when only drawer present")
}
@Test
fun testStripReviewDataDrawers_preservesOtherDrawers() {
val body = """
Question text.
:LOGBOOK:
CLOCK: [2026-03-01 Sat 10:00]--[2026-03-01 Sat 11:00] => 1:00
:END:
:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:
Final content.
""".trimIndent()
val result = HeadingTextUtils.stripReviewDataDrawers(body)
println("Input:\n$body")
println("Output:\n$result")
assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker")
assertTrue(result.contains(":LOGBOOK:"), "Should preserve LOGBOOK drawer")
assertTrue(result.contains("CLOCK:"), "Should preserve LOGBOOK content")
}
// ============ replaceHeadingBody Tests ============
@Test
fun testReplaceHeadingBody_preservesLevel0Subheadings() {
// Test for level-0 (file-level) content with sub-headings
val content = """:PROPERTIES:
:ID: file-level-id
:END:
#+title: 2026-03-15
Main body content.
,* Sub heading 1
Content under sub heading 1.
,* Sub heading 2
Content under sub heading 2.
"""
// Replace the body (everything after title, before sub-headings)
val newBody = "Updated body content.\n\nMore details here."
val result = HeadingTextUtils.replaceHeadingBody(
content, 0, null, newBody
)
println("=== Result ===")
// Verify title is preserved
assertTrue(result.contains("#+title: 2026-03-15"), "Should preserve title")
}
@Test
fun testReplaceHeadingBody_level0_withEmptyBody() {
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
Body content.
,* Sub heading
More content.
"""
// Replace with empty body - sub-headings are part of the body and get removed
val result = HeadingTextUtils.replaceHeadingBody(
content, 0, null, ""
)
// With the new behavior, empty body removes all sub-headings since they're part of the body
// Title and properties are preserved
assertTrue(result.contains("#+title: Test"), "Should preserve title")
assertTrue(result.contains(":PROPERTIES:"), "Should preserve properties")
// Sub-headings are part of the body, so empty body means they're removed
assertTrue(!result.contains("* Sub heading"), "Should not contain sub heading with empty body")
assertTrue(!result.contains("More content."), "Should not contain sub heading content")
}
@Test
fun testReplaceHeadingBody_level0_noSubheadings() {
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
Body content with no sub-headings.
"""
val newBody = "Completely updated content."
val result = HeadingTextUtils.replaceHeadingBody(
content, 0, null, newBody
)
// Verify content was replaced
assertTrue(result.contains("Completely updated content."), "Should contain new body")
assertTrue(!result.contains("Body content with no sub-headings."), "Should not contain old body")
}
@Test
fun testReplaceHeadingBody_regularHeading_stillWorks() {
// Ensure regular heading (level > 0) still works after the level-0 changes
val content = """* TODO Test Heading
:PROPERTIES:
:ID: test-id
:END:
Regular heading body content.
,** Sub heading
Sub heading content.
"""
val newBody = "Updated regular body content."
val result = HeadingTextUtils.replaceHeadingBody(
content, 0, null, newBody
)
// Verify heading is preserved
assertTrue(result.contains("* TODO Test Heading"), "Should preserve heading line")
// Verify body was updated
assertTrue(result.contains("Updated regular body content."), "Should contain new body")
// Verify properties are preserved
assertTrue(result.contains(":PROPERTIES:"), "Should preserve properties drawer")
assertTrue(result.contains(":ID: test-id"), "Should preserve ID")
// Sub-headings are part of the body — newBody is the complete replacement
assertTrue(!result.contains("** Sub heading"), "Should NOT preserve sub heading — newBody is complete replacement")
assertTrue(!result.contains("Sub heading content."), "Should NOT preserve sub heading content")
}
@Test
fun testReplaceHeadingBody_level0_deepNestedChildren() {
// Test deeply nested sub-headings (***)
// With the new behavior, sub-headings are part of the body and get replaced
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Root
Main body.
,* Level 1
Content.
,** Level 2A
Content.
,*** Level 3
Deep content.
,** Level 2B
More content.
"""
val newBody = "Updated main body."
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
assertTrue(result.contains("#+title: Root"), "Should preserve title")
// Sub-headings are part of the body and get replaced
assertTrue(!result.contains("* Level 1"), "Should not contain old level 1")
assertTrue(!result.contains("** Level 2A"), "Should not contain old level 2A")
assertTrue(!result.contains("*** Level 3"), "Should not contain old level 3")
assertTrue(!result.contains("** Level 2B"), "Should not contain old level 2B")
assertTrue(result.contains("Updated main body."), "Should contain new body")
}
@Test
fun testReplaceHeadingBody_level0_emptyBody_nestedChildren() {
// Test empty body - sub-headings are part of the body and get removed
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Root
,* Level 1
Content.
,** Level 2
Deep content.
"""
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "")
assertTrue(result.contains("#+title: Root"), "Should preserve title")
// Sub-headings are part of the body, so empty body means they're removed
assertTrue(!result.contains("* Level 1"), "Should not contain level 1")
assertTrue(!result.contains("** Level 2"), "Should not contain level 2")
}
@Test
fun testReplaceHeadingBody_level0_whitespaceBody() {
// Test with whitespace-only body content
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
,* Sub heading
Content.
"""
val newBody = "Actual body content."
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
assertTrue(result.contains("Actual body content."), "Should contain new body")
// Sub-heading was part of the original body and got replaced
assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
}
@Test
fun testReplaceHeadingBody_level0_withReviewData() {
// Test level-0 nodes with REVIEW_DATA drawers in the body
// With new behavior, sub-headings are part of the body
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
Body with flashcard.
:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:
,* Sub heading
Content.
"""
val newBody = "Updated body without review data."
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain review data")
assertTrue(result.contains("Updated body without review data."), "Should contain new body")
// Sub-heading was part of the body and got replaced
assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
}
@Test
fun testReplaceHeadingBody_level0_propertiesWithSpecialValues() {
// Test properties with special values (aliases, geo coords, etc.)
val content = """:PROPERTIES:
:ID: test-id
:PRIORITY: A
:ROAM_ALIASES: "alias1" "alias2"
:GEO_COORDS: "37.7749,-122.4194"
:END:
#+title: Test
Body content.
,* Sub heading
Content.
"""
val newBody = "Updated body."
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
assertTrue(result.contains(":ID: test-id"), "Should preserve ID")
assertTrue(result.contains(":PRIORITY: A"), "Should preserve priority")
assertTrue(result.contains(":ROAM_ALIASES:"), "Should preserve aliases")
assertTrue(result.contains(":GEO_COORDS:"), "Should preserve geo coords")
}
@Test
fun testReplaceHeadingBody_level0_noBlankLineBeforeSubheading() {
// Test with sub-heading immediately after title (no blank line)
// With the new behavior, sub-headings are part of the body and get replaced
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
,* Sub heading
Content.
"""
val newBody = "Updated body."
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
assertTrue(result.contains("Updated body."), "Should contain new body")
// Sub-heading was part of the body and got replaced
assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
}
@Test
fun testReplaceHeadingBody_level0_multipleReviewDataDrawers() {
// Test multiple REVIEW_DATA drawers in level-0 content
// With the new behavior, sub-headings are part of the body and get replaced
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
Body content.
:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:
Some middle content.
:REVIEW_DATA:
|position|ease|box|interval|due|
|back|2.5|1|1|2026-03-01|
:END:
Final content.
,* Sub heading
Content.
"""
val newBody = "New body."
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain any review data")
assertTrue(result.contains("New body."), "Should contain new body")
// Sub-heading was part of the body and got replaced
assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
}
@Test
fun testReplaceHeadingBody_level0_newBodyContainsSubheadings_noDuplication() {
// This test ensures that when the new body contains sub-headings,
// they are NOT duplicated from the original file's sub-headings
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
Original body.
,* Original Sub heading
Original content.
,* Another Original
More original.
"""
// User edits and the new body includes the same sub-headings
val newBody = """
Updated body.
,* Original Sub heading
Updated content.
,* Another Original
More updated content.
"""
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
// Verify no duplication - each sub-heading should appear exactly once
val originalSubHeadingCount = result.count { it == '*' && result.indexOf("\n* ") >= 0 }
// The new body should replace the original content, not append to it
assertTrue(result.contains("Updated body."), "Should contain new body")
assertTrue(result.contains("Updated content."), "Should contain updated content")
assertTrue(result.contains("More updated content."), "Should contain more updated content")
// Original content should NOT be present (it was replaced)
assertTrue(!result.contains("Original body."), "Should not contain original body")
assertTrue(!result.contains("Original content."), "Should not contain original content")
}
@Test
fun testReplaceHeadingBody_level0_bodyReplacesAll() {
// The new body completely replaces the body section
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
,* Many sub headings
,* In the original
,* File content
And some other content.
"""
val newBody = "Completely new content with\n* One sub heading"
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
// Verify the entire body was replaced
assertTrue(result.contains("#+title: Test"), "Should preserve title")
assertTrue(result.contains("Completely new content with"), "Should contain new body")
assertTrue(result.contains("* One sub heading"), "Should contain new sub heading")
// Original content should NOT be present
assertTrue(!result.contains("Many sub headings"), "Should not contain original sub heading")
assertTrue(!result.contains("In the original"), "Should not contain original content")
assertTrue(!result.contains("File content"), "Should not contain original content")
}
}HeadingTextUtilsPropertyTest
Uses property-based testing with OrgContentGenerators to verify replaceHeadingBody invariants: sub-heading preservation, metadata preservation, body replacement correctness, edge case handling (no properties drawer, special characters, unicode), idempotence, and whitespace tolerance.
package computer.whatthefuck.arcology.editor
import computer.whatthefuck.arcology.generators.OrgContentGenerators
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
class HeadingTextUtilsPropertyTest : FunSpec({
context("Property: Body replacement correctness") {
test("newBody is the complete replacement - sub-headings come from newBody, not file") {
forAll(OrgContentGenerators.orgDocument(5)) { content ->
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new body")
// newBody has no asterisk lines, so output should not have sub-headings from file
val outputSubheadings = result.lines().filter { it.startsWith("*") }
outputSubheadings.size == 0
}
}
test("deep nesting replaced by newBody") {
forAll(Arb.int(1..10)) { level ->
val stars = "*".repeat(level)
val subStars = "*".repeat(level + 1)
val content = """$stars Heading
:PROPERTIES:
:ID: test
:END:
Some body content here.
$subStars Sub Heading
:PROPERTIES:
:ID: sub-test
:END:
Sub content.
"""
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
result.contains("Heading") && !result.contains("Sub Heading")
}
}
}
context("Property: Metadata preservation") {
test(":PROPERTIES: drawer structure preserved") {
forAll(
OrgContentGenerators.validId,
Arb.list(OrgContentGenerators.propertyValue, 1..10)
) { id, propValues ->
val props = propValues.joinToString("\n") { ":VALUE: $it" }
val content = """:PROPERTIES:
:ID: $id
$props
:END:
#+title: Test
Body.
,* Sub
Content.
"""
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
result.contains(":PROPERTIES:") && result.contains(":END:")
}
}
test("#+title: preserved through edits") {
forAll(Arb.string(3..50)) { title ->
val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: $title
Body.
,* Sub
Content.
"""
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
result.contains("#+title: $title")
}
}
}
context("Property: Body replacement correctness") {
test("body replacement doesn't affect heading position") {
forAll(OrgContentGenerators.orgDocument(3)) { content ->
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "replacement")
// The title should still be near the start, not pushed down
result.lines().indexOfFirst { it.startsWith("#+title:") } < 10
}
}
test("empty body produces valid output") {
forAll(OrgContentGenerators.orgDocument(5)) { content ->
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "")
// Should not crash, should preserve structure
result.lines().any { it.startsWith("#+title:") } == content.lines().any { it.startsWith("#+title:") }
}
}
test("newBody with sub-headings appears in output") {
forAll(OrgContentGenerators.orgDocument(3)) { content ->
val newBody = "* New Sub\nContent."
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
result.contains("* New Sub")
}
}
}
context("Property: Input edge cases") {
test("handles content without properties drawer") {
forAll(OrgContentGenerators.orgDocument(3)) { content ->
// Remove properties if present
val cleanContent = content.replace(":PROPERTIES:", "").replace(":END:", "")
val result = try {
HeadingTextUtils.replaceHeadingBody(cleanContent, 0, null, "new")
true
} catch (e: Exception) {
false
}
result // Should not throw
}
}
}
context("Property: Idempotence") {
test("replacing same body twice produces same result") {
forAll(OrgContentGenerators.orgDocument(3)) { content ->
val body = "idempotent body"
val result1 = HeadingTextUtils.replaceHeadingBody(content, 0, null, body)
val result2 = HeadingTextUtils.replaceHeadingBody(content, 0, null, body)
result1 == result2
}
}
}
context("Property: Whitespace tolerance") {
test("handles varying whitespace in properties") {
forAll(
Arb.string(1..20, Codepoint.alphanumeric()).map { ":$it: $it" },
Arb.string(1..20, Codepoint.alphanumeric()).map { ":$it: $it" }
) { prop1, prop2 ->
val content = """:PROPERTIES:
$prop1
$prop2
:END:
#+title: Test
Body
,* Sub
Content
"""
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
result.contains(prop1) && result.contains(prop2)
}
}
test("handles leading whitespace in content") {
forAll(Arb.string(3..30)) { title ->
val content = """
:PROPERTIES:
:ID: test-id
:END:
#+title: $title
Body
* Sub
Content
""".trimIndent()
val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
result.contains("#+title: $title")
}
}
}
})OrgDocumentEditorTest
Tests heading position finding logic via findHeadingPosition (regex-based ID matching to locate the correct heading line) and its integration with PropertyDrawerUtils.setProperty across simple, nested, file-keyword, UUID, and spacing scenarios. Also covers file-level property operations (setFileLevelProperty, removeFileLevelProperty) including drawer creation, property update/removal, and preservation of non-targeted properties.
package computer.whatthefuck.arcology.editor
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse
/**
* Tests for OrgDocumentEditor's heading position finding logic,
* its integration with PropertyDrawerUtils, and file-level property handling.
*/
class OrgDocumentEditorPositionTest {
/**
* Reproduce the regex-based heading position finding from OrgDocumentEditor.
*/
private fun findHeadingPosition(content: String, nodeId: String, nodeLevel: Int): Int? {
if (nodeLevel == 0) return 0
val idPattern = Regex(":ID:\\s*${Regex.escape(nodeId)}\\s*$", RegexOption.MULTILINE)
val idMatch = idPattern.find(content) ?: return null
val beforeId = content.substring(0, idMatch.range.first)
val headingPattern = Regex("^\\*+ .*$", RegexOption.MULTILINE)
val lastHeadingMatch = headingPattern.findAll(beforeId).lastOrNull()
return lastHeadingMatch?.range?.first ?: 0
}
// --- Heading-level position tests ---
@Test
fun testFindPositionAndSetPropertySimple() {
val content = """* Test Heading
:PROPERTIES:
:ID: abc-123
:END:
Some body content."""
val position = findHeadingPosition(content, "abc-123", 1)
assertNotNull(position, "Should find heading position")
assertEquals(0, position, "Heading should be at position 0")
val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
assertNotNull(result, "setProperty should succeed")
assertTrue(result.contains(":PINNED: t"), "Should contain new property")
}
@Test
fun testFindPositionAndSetPropertyWithFileKeywords() {
val content = """:PROPERTIES:
:ID: file-level-id
:END:
#+title: My Note
,* Important Heading
:PROPERTIES:
:ID: heading-id-123
:END:
Some content here."""
val position = findHeadingPosition(content, "heading-id-123", 1)
assertNotNull(position, "Should find heading position")
val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
assertNotNull(result, "setProperty should succeed for level-1 node with file keywords")
assertTrue(result.contains(":PINNED: t"), "Should contain PINNED property")
assertTrue(result.contains(":ID: heading-id-123"), "Should preserve ID")
}
@Test
fun testFindPositionAndSetPropertyNestedHeading() {
val content = """* Parent Heading
:PROPERTIES:
:ID: parent-id
:END:
Parent content.
,** Child Heading
:PROPERTIES:
:ID: child-id
:END:
Child content."""
val position = findHeadingPosition(content, "child-id", 2)
assertNotNull(position, "Should find child heading position")
val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
assertNotNull(result, "setProperty should succeed for nested heading")
assertTrue(result.contains(":PINNED: t"), "Should contain PINNED property")
val childSection = result.substring(result.indexOf("** Child Heading"))
assertTrue(childSection.contains(":PINNED: t"), "PINNED should be in child section")
}
@Test
fun testFindPositionLevel0ReturnsZero() {
val content = """:PROPERTIES:
:ID: file-level-id
:END:
#+title: My Note
,* Heading
:PROPERTIES:
:ID: heading-id
:END:"""
val position = findHeadingPosition(content, "file-level-id", 0)
assertNotNull(position)
assertEquals(0, position)
}
@Test
fun testPropertyDrawerUtilsCannotHandleLevel0() {
// Documents that PropertyDrawerUtils cannot handle file-level property drawers.
// OrgDocumentEditor has separate logic for this.
val content = """:PROPERTIES:
:ID: file-level-id
:END:
#+title: My Note"""
val result = PropertyDrawerUtils.setProperty(content, 0, "PINNED", "t")
assertNull(result, "PropertyDrawerUtils returns null for level-0 (no OrgHeading at position 0)")
}
@Test
fun testFindPositionWithUuidId() {
val nodeId = "20240101T120000.123456"
val content = """* Daily Note
:PROPERTIES:
:ID: $nodeId
:END:
Today's notes."""
val position = findHeadingPosition(content, nodeId, 1)
assertNotNull(position)
assertEquals(0, position)
val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
assertNotNull(result)
assertTrue(result.contains(":PINNED: t"))
}
@Test
fun testFindPositionWithSpacesAroundId() {
val content = """* My Heading
:PROPERTIES:
:ID: abc-123-def
:END:
Content."""
val position = findHeadingPosition(content, "abc-123-def", 1)
assertNotNull(position)
assertEquals(0, position)
val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
assertNotNull(result)
assertTrue(result.contains(":PINNED: t"))
}
}
/**
* Tests for OrgDocumentEditor's file-level property operations.
* These test the setFileLevelProperty/removeFileLevelProperty methods
* which are used for level-0 nodes.
*/
class FileLevelPropertyTest {
// We test the methods indirectly through a helper that mirrors the OrgDocumentEditor logic
private fun setFileLevelProperty(content: String, key: String, value: String): String {
val propertyLine = ":$key: $value"
val trimmed = content.trimStart()
if (trimmed.startsWith(":PROPERTIES:")) {
val endIdx = content.indexOf(":END:")
if (endIdx == -1) return content
val drawerContent = content.substring(0, endIdx)
val propertyPattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
return if (propertyPattern.containsMatchIn(drawerContent)) {
val updatePattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
content.replace(updatePattern) { match ->
if (match.range.first < endIdx) propertyLine else match.value
}
} else {
content.substring(0, endIdx) + "$propertyLine\n" + content.substring(endIdx)
}
} else {
return ":PROPERTIES:\n$propertyLine\n:END:\n$content"
}
}
private fun removeFileLevelProperty(content: String, key: String): String {
val trimmed = content.trimStart()
if (!trimmed.startsWith(":PROPERTIES:")) return content
val endIdx = content.indexOf(":END:")
if (endIdx == -1) return content
val drawerContent = content.substring(0, endIdx)
val propertyPattern = Regex("^:${Regex.escape(key)}:.*\\n?", RegexOption.MULTILINE)
if (!propertyPattern.containsMatchIn(drawerContent)) return content
val newDrawer = drawerContent.replace(propertyPattern, "")
val remainingProps = newDrawer.lines().filter { line ->
val t = line.trim()
t.startsWith(":") && !t.startsWith(":PROPERTIES:") && t != ":"
}
if (remainingProps.isEmpty()) {
val fullEndIdx = content.indexOf(":END:") + ":END:".length
val afterDrawer = content.substring(fullEndIdx).trimStart('\n')
return afterDrawer
}
return content.replace(propertyPattern) { match ->
if (match.range.first < endIdx) "" else match.value
}
}
@Test
fun testSetPropertyInExistingFileDrawer() {
val content = """:PROPERTIES:
:ID: file-level-id
:END:
#+title: My Note
"""
val result = setFileLevelProperty(content, "PINNED", "t")
assertTrue(result.contains(":PINNED: t"), "Should add PINNED property")
assertTrue(result.contains(":ID: file-level-id"), "Should preserve ID")
assertTrue(result.contains(":PROPERTIES:"), "Should keep drawer")
assertTrue(result.contains(":END:"), "Should keep END")
assertTrue(result.contains("#+title: My Note"), "Should preserve title")
}
@Test
fun testUpdateExistingPropertyInFileDrawer() {
val content = """:PROPERTIES:
:ID: file-level-id
:PINNED: t
:END:
#+title: My Note
"""
val result = setFileLevelProperty(content, "PINNED", "false")
assertTrue(result.contains(":PINNED: false"), "Should update PINNED value")
assertFalse(result.contains(":PINNED: t"), "Should not have old value")
}
@Test
fun testSetPropertyCreatesDrawer() {
val content = """#+title: My Note
,* First Heading
Some content."""
val result = setFileLevelProperty(content, "PINNED", "t")
assertTrue(result.startsWith(":PROPERTIES:"), "Should create drawer at start")
assertTrue(result.contains(":PINNED: t"), "Should contain PINNED")
assertTrue(result.contains(":END:"), "Should have END")
assertTrue(result.contains("#+title: My Note"), "Should preserve original content")
}
@Test
fun testRemovePropertyFromFileDrawer() {
val content = """:PROPERTIES:
:ID: file-level-id
:PINNED: t
:END:
#+title: My Note
"""
val result = removeFileLevelProperty(content, "PINNED")
assertFalse(result.contains(":PINNED:"), "Should remove PINNED")
assertTrue(result.contains(":ID: file-level-id"), "Should preserve ID")
assertTrue(result.contains(":PROPERTIES:"), "Drawer should remain (still has ID)")
}
@Test
fun testRemoveOnlyNonIdPropertyKeepsDrawer() {
val content = """:PROPERTIES:
:ID: file-level-id
:PINNED: t
:END:
#+title: My Note
"""
val result = removeFileLevelProperty(content, "PINNED")
assertTrue(result.contains(":PROPERTIES:"), "Drawer should remain for ID")
assertTrue(result.contains(":ID: file-level-id"), "ID should remain")
assertFalse(result.contains(":PINNED:"), "PINNED should be removed")
}
@Test
fun testRemoveNonexistentPropertyNoChange() {
val content = """:PROPERTIES:
:ID: file-level-id
:END:
#+title: My Note
"""
val result = removeFileLevelProperty(content, "PINNED")
assertEquals(content, result, "Should return unchanged content")
}
@Test
fun testRemovePropertyNoDrawerNoChange() {
val content = """#+title: My Note
,* Heading
"""
val result = removeFileLevelProperty(content, "PINNED")
assertEquals(content, result, "Should return unchanged content")
}
@Test
fun testSetPropertyPreservesDrawerOrder() {
val content = """:PROPERTIES:
:ID: file-level-id
:ROAM_ALIASES: "Alias One"
:END:
#+title: My Note
"""
val result = setFileLevelProperty(content, "PINNED", "t")
// PINNED should be added before :END:
val pinnedIdx = result.indexOf(":PINNED: t")
val endIdx = result.indexOf(":END:")
assertTrue(pinnedIdx < endIdx, "PINNED should be before :END:")
assertTrue(pinnedIdx > result.indexOf(":ID:"), "PINNED should be after :ID:")
}
}PositionBasedEditorTest
Tests the position-based editor methods (updateTodoStateByPosition, updatePlanningInfoByPosition) that work on headings without :ID: properties. Uses TestFileSystem and a no-op FileIndexingService to instantiate a real OrgDocumentEditor.
package computer.whatthefuck.arcology.editor
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.FileIndexingService
import computer.whatthefuck.arcology.indexer.FileSystemInterface
import computer.whatthefuck.arcology.indexer.TestFileSystem
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse
class PositionBasedEditorTest {
private fun createEditor(fs: FileSystemInterface): OrgDocumentEditor {
val repo = mockk<RoamRepository>(relaxed = true)
val indexer = mockk<FileIndexingService>(relaxed = true)
return OrgDocumentEditor(fs, repo, indexer)
}
@Test
fun `updateTodoStateByPosition sets TODO on heading without ID`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* Task without ID\nBody text.\n")
val editor = createEditor(fs)
val result = editor.updateTodoStateByPosition("/test.org", 0, "TODO")
assertTrue(result is EditResult.Success, "Should succeed: ${(result as? EditResult.Error)?.message}")
val content = fs.readFile("/test.org")
assertTrue(content.contains("* TODO Task without ID"), "Should set TODO state")
}
@Test
fun `updateTodoStateByPosition changes TODO to DONE`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Task\nBody.\n")
val editor = createEditor(fs)
val result = editor.updateTodoStateByPosition("/test.org", 0, "DONE")
assertTrue(result is EditResult.Success)
val content = fs.readFile("/test.org")
assertTrue(content.contains("* DONE Task"), "Should change TODO to DONE")
assertFalse(content.contains("* TODO Task"), "Should not have old TODO state")
}
@Test
fun `updateTodoStateByPosition appends LOGBOOK entry on state change`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Task\nBody.\n")
val editor = createEditor(fs)
editor.updateTodoStateByPosition("/test.org", 0, "DONE")
val content = fs.readFile("/test.org")
assertTrue(content.contains(":LOGBOOK:"), "Should add LOGBOOK drawer")
assertTrue(content.contains("State \"DONE\""), "Should log DONE state change")
}
@Test
fun `updateTodoStateByPosition with repeater reschedules instead of marking DONE`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Daily task\nSCHEDULED: <2026-07-01 Wed +1d>\nBody.\n")
val editor = createEditor(fs)
editor.updateTodoStateByPosition("/test.org", 0, "DONE")
val content = fs.readFile("/test.org")
// Should flip back to TODO and advance the date
assertTrue(content.contains("* TODO Daily task"), "Should flip back to TODO (repeater)")
assertFalse(content.contains("* DONE Daily task"), "Should NOT stay DONE (repeater reschedule)")
assertTrue(content.contains("<2026-07-02"), "Should advance SCHEDULED by 1 day")
}
@Test
fun `updateTodoStateByPosition with repeater logs Emacs-style DONE from active state`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Daily task\nSCHEDULED: <2026-07-01 Wed +1d>\nBody.\n")
val editor = createEditor(fs)
editor.updateTodoStateByPosition("/test.org", 0, "DONE")
val content = fs.readFile("/test.org")
// Emacs logs the user's action as the toState: State "DONE" from "TODO"
assertTrue(
content.contains("State \"DONE\" from \"TODO\""),
"Repeater logbook entry should be Emacs-style (DONE from TODO), got:\n$content"
)
assertFalse(
content.contains("State \"TODO\" from \"DONE\""),
"Repeater logbook entry should NOT be inverted, got:\n$content"
)
}
@Test
fun `updateTodoStateByPosition with REPEAT_TO_STATE uses that state`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Daily task\nSCHEDULED: <2026-07-01 Wed +1d>\n:PROPERTIES:\n:REPEAT_TO_STATE: NEXT\n:END:\nBody.\n")
val editor = createEditor(fs)
editor.updateTodoStateByPosition("/test.org", 0, "DONE")
val content = fs.readFile("/test.org")
assertTrue(content.contains("* NEXT Daily task"), "Should flip to REPEAT_TO_STATE (NEXT)")
}
@Test
fun `updateTodoStateByPosition no state change does not log`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Task\nBody.\n")
val editor = createEditor(fs)
editor.updateTodoStateByPosition("/test.org", 0, "TODO")
val content = fs.readFile("/test.org")
assertFalse(content.contains(":LOGBOOK:"), "Should not log when state doesn't change")
}
@Test
fun `updatePlanningInfoByPosition inserts SCHEDULED on heading without ID`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Task\nBody.\n")
val editor = createEditor(fs)
val result = editor.updatePlanningInfoByPosition("/test.org", 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat 09:00>")
assertTrue(result is EditResult.Success)
val content = fs.readFile("/test.org")
assertTrue(content.contains("SCHEDULED: <2026-08-15 Sat 09:00>"), "Should insert SCHEDULED line")
}
@Test
fun `updatePlanningInfoByPosition removes SCHEDULED`() = runTest {
val fs = TestFileSystem()
fs.addFile("/test.org", "* TODO Task\nSCHEDULED: <2026-08-15 Sat 09:00>\nBody.\n")
val editor = createEditor(fs)
val result = editor.updatePlanningInfoByPosition("/test.org", 0, PlanningKind.SCHEDULED, null)
assertTrue(result is EditResult.Success)
val content = fs.readFile("/test.org")
assertFalse(content.contains("SCHEDULED:"), "Should remove SCHEDULED line")
}
@Test
fun `updateTodoStateByPosition returns error for missing file`() = runTest {
val fs = TestFileSystem()
val editor = createEditor(fs)
val result = editor.updateTodoStateByPosition("/nonexistent.org", 0, "TODO")
assertTrue(result is EditResult.Error, "Should return error for missing file")
}
@Test
fun `updateTodoStateByPosition with repeater and priority cookie keeps SCHEDULED on its own line`() = runTest {
val fs = TestFileSystem()
fs.addFile(
"/test.org",
"* TODO [#A] Work on Arcology once a week\n" +
"SCHEDULED: <2025-12-20 Sat ++1w>\n" +
":PROPERTIES:\n" +
":STYLE: habit\n" +
":ID: 20210925T175056.966417\n" +
":END:\n" +
":LOGBOOK:\n" +
":END:\n" +
"Body.\n"
)
val editor = createEditor(fs)
editor.updateTodoStateByPosition("/test.org", 0, "DONE")
val content = fs.readFile("/test.org")
// Heading line must remain a single line; SCHEDULED must stay on its own line below it
val lines = content.lines()
val headingLine = lines.first { it.startsWith("*") }
assertTrue(headingLine.startsWith("* NEXT [#A] Work on Arcology") || headingLine.startsWith("* TODO [#A] Work on Arcology"),
"Heading should flip back to active state with priority preserved, got: $headingLine")
assertFalse(headingLine.contains("SCHEDULED:"), "SCHEDULED must NOT be on the heading line, got: $headingLine")
assertTrue(lines.any { it.trim().startsWith("SCHEDULED:") }, "SCHEDULED should be on its own line")
}
// --- Filetag (#+FILETAGS:) tests ---
@Test
fun `addFileTagToContent adds CLOSED to file with no existing filetags`() {
val editor = createEditor(TestFileSystem())
val content = "#+title: My Project\n\n* Task 1\nBody.\n"
val result = editor.addFileTagToContent(content, "CLOSED")
assertTrue(result.contains("#+FILETAGS: :CLOSED:"))
// Filetags line should be on its own line after title, not on the same line
val lines = result.lines()
val titleIdx = lines.indexOfFirst { it.startsWith("#+title:") }
val filetagsIdx = lines.indexOfFirst { it.startsWith("#+FILETAGS:") }
assertTrue(filetagsIdx > titleIdx, "FILETAGS should be after title")
assertTrue(lines[filetagsIdx] == "#+FILETAGS: :CLOSED:", "FILETAGS line should be standalone, got: ${lines[filetagsIdx]}")
}
@Test
fun `addFileTagToContent appends to existing filetags`() {
val editor = createEditor(TestFileSystem())
val content = "#+title: My Project\n#+FILETAGS: :work:project:\n\n* Task 1\n"
val result = editor.addFileTagToContent(content, "CLOSED")
// Should NOT create a second FILETAGS line
val filetagsCount = Regex("(?im)^#\\+FILETAGS:").findAll(result).count()
assertEquals(1, filetagsCount, "Should have exactly one FILETAGS line, got $filetagsCount")
assertTrue(result.contains(":work:project:CLOSED:"), "Should append CLOSED to existing tags, got: $result")
}
@Test
fun `addFileTagToContent is no-op when tag already present`() {
val editor = createEditor(TestFileSystem())
val content = "#+FILETAGS: :work:CLOSED:\n\n* Task\n"
val result = editor.addFileTagToContent(content, "CLOSED")
assertEquals(content, result, "Should be unchanged when tag already present")
}
@Test
fun `addFileTagToContent inserts at top when no title`() {
val editor = createEditor(TestFileSystem())
val content = "* Task\nBody.\n"
val result = editor.addFileTagToContent(content, "CLOSED")
assertTrue(result.startsWith("#+FILETAGS: :CLOSED:"), "Should insert at top when no title, got: $result")
}
@Test
fun `removeFileTagFromContent removes single tag from multi-tag line`() {
val editor = createEditor(TestFileSystem())
val content = "#+FILETAGS: :work:CLOSED:project:\n\n* Task\n"
val result = editor.removeFileTagFromContent(content, "CLOSED")
val filetagsCount = Regex("(?im)^#\\+FILETAGS:").findAll(result).count()
assertEquals(1, filetagsCount, "Should still have one FILETAGS line")
assertTrue(result.contains(":work:project:"), "Should remove CLOSED, keep others, got: $result")
assertFalse(result.contains("CLOSED"), "Should not contain CLOSED, got: $result")
}
@Test
fun `removeFileTagFromContent removes whole line when last tag removed`() {
val editor = createEditor(TestFileSystem())
val content = "#+title: My Project\n#+FILETAGS: :CLOSED:\n\n* Task\n"
val result = editor.removeFileTagFromContent(content, "CLOSED")
assertFalse(result.contains("FILETAGS"), "Should remove FILETAGS line entirely, got: $result")
assertTrue(result.contains("#+title: My Project"), "Should preserve title")
}
@Test
fun `removeFileTagFromContent is no-op when tag not present`() {
val editor = createEditor(TestFileSystem())
val content = "#+FILETAGS: :work:project:\n\n* Task\n"
val result = editor.removeFileTagFromContent(content, "CLOSED")
assertEquals(content, result, "Should be unchanged when tag not present")
}
}PropertyDrawerUtilsTest
Tests setProperty and removeProperty on heading-level property drawers: adding to existing drawers, updating values, preserving unrelated properties, creating new drawers (plain, after TODO, after tags), nested heading isolation, and invalid position handling. removeProperty tests include drawer removal when last property is deleted, no-op for nonexistent keys, and nested scope isolation.
package computer.whatthefuck.arcology.editor
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse
class PropertyDrawerUtilsTest {
@Test
fun testAddPropertyToExistingDrawer() {
val content = """
|* Test Heading
|:PROPERTIES:
|:ID: test-id-123
|:END:
|Some body content.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.setProperty(
content, headingPosition, "GEO_COORDS", "37.7749,-122.4194"
)
assertNotNull(result)
assertTrue(result.contains(":PROPERTIES:"))
assertTrue(result.contains(":ID: test-id-123"))
assertTrue(result.contains(":GEO_COORDS: 37.7749,-122.4194"))
assertTrue(result.contains(":END:"))
assertTrue(result.contains("Some body content."))
}
@Test
fun testUpdateExistingProperty() {
val content = """
|* Test Heading
|:PROPERTIES:
|:ID: test-id-123
|:GEO_COORDS: 0.0,0.0
|:END:
|Some body content.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.setProperty(
content, headingPosition, "GEO_COORDS", "37.7749,-122.4194"
)
assertNotNull(result)
assertTrue(result.contains(":GEO_COORDS: 37.7749,-122.4194"))
assertFalse(result.contains(":GEO_COORDS: 0.0,0.0"))
}
@Test
fun testPreserveOtherProperties() {
val content = """
|* Test Heading
|:PROPERTIES:
|:ID: test-id-123
|:CATEGORY: notes
|:END:
|Body text here.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.setProperty(
content, headingPosition, "GEO_COORDS", "40.7128,-74.0060"
)
assertNotNull(result)
assertTrue(result.contains(":ID: test-id-123"))
assertTrue(result.contains(":CATEGORY: notes"))
assertTrue(result.contains(":GEO_COORDS: 40.7128,-74.0060"))
}
@Test
fun testCreateNewPropertiesDrawer() {
val content = """
|* Test Heading
|Some body content without properties.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.setProperty(
content, headingPosition, "GEO_COORDS", "51.5074,-0.1278"
)
assertNotNull(result)
assertTrue(result.contains(":PROPERTIES:"))
assertTrue(result.contains(":GEO_COORDS: 51.5074,-0.1278"))
assertTrue(result.contains(":END:"))
assertTrue(result.contains("Some body content"))
}
@Test
fun testCreateDrawerAfterTodoState() {
val content = """
|* TODO Test Task
|Body content here.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.setProperty(
content, headingPosition, "GEO_COORDS", "48.8566,2.3522"
)
assertNotNull(result)
assertTrue(result.contains("* TODO Test Task"))
assertTrue(result.contains(":PROPERTIES:"))
assertTrue(result.contains(":GEO_COORDS: 48.8566,2.3522"))
assertTrue(result.contains(":END:"))
}
@Test
fun testCreateDrawerAfterHeadingWithTags() {
val content = """
|* Heading with tags :tag1:tag2:
|Body content.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.setProperty(
content, headingPosition, "GEO_COORDS", "35.6762,139.6503"
)
assertNotNull(result)
assertTrue(result.contains("* Heading with tags :tag1:tag2:"))
assertTrue(result.contains(":PROPERTIES:"))
assertTrue(result.contains(":GEO_COORDS: 35.6762,139.6503"))
}
@Test
fun testNestedHeading() {
val content = """
|* Parent Heading
|:PROPERTIES:
|:ID: parent-id
|:END:
|Parent content.
|
|** Child Heading
|:PROPERTIES:
|:ID: child-id
|:END:
|Child content.
""".trimMargin()
// Find position of child heading
val childPosition = content.indexOf("** Child Heading")
val result = PropertyDrawerUtils.setProperty(
content, childPosition, "GEO_COORDS", "52.5200,13.4050"
)
assertNotNull(result)
// Parent should be unchanged - check it doesn't have GEO_COORDS after parent-id
assertTrue(result.contains(":ID: parent-id"))
// Child should have new property
assertTrue(result.contains(":ID: child-id"))
assertTrue(result.contains(":GEO_COORDS: 52.5200,13.4050"))
}
@Test
fun testInvalidHeadingPositionReturnsNull() {
val content = """
|* Test Heading
|Body content.
""".trimMargin()
val result = PropertyDrawerUtils.setProperty(
content, 9999, "GEO_COORDS", "0.0,0.0"
)
assertNull(result)
}
@Test
fun testPositionInBodyReturnsNull() {
val content = """
|* Test Heading
|Body content here.
""".trimMargin()
// Position in the middle of body content
val bodyPosition = content.indexOf("Body content")
val result = PropertyDrawerUtils.setProperty(
content, bodyPosition, "GEO_COORDS", "0.0,0.0"
)
assertNull(result)
}
// Tests for removeProperty
@Test
fun testRemovePropertyFromDrawer() {
val content = """
|* Test Heading
|:PROPERTIES:
|:ID: test-id-123
|:PINNED: t
|:END:
|Some body content.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.removeProperty(
content, headingPosition, "PINNED"
)
assertNotNull(result)
assertTrue(result.contains(":PROPERTIES:"))
assertTrue(result.contains(":ID: test-id-123"))
assertFalse(result.contains(":PINNED:"))
assertTrue(result.contains(":END:"))
assertTrue(result.contains("Some body content."))
}
@Test
fun testRemoveOnlyPropertyRemovesDrawer() {
val content = """
|* Test Heading
|:PROPERTIES:
|:PINNED: t
|:END:
|Some body content.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.removeProperty(
content, headingPosition, "PINNED"
)
assertNotNull(result)
assertFalse(result.contains(":PROPERTIES:"))
assertFalse(result.contains(":PINNED:"))
assertFalse(result.contains(":END:"))
assertTrue(result.contains("* Test Heading"))
assertTrue(result.contains("Some body content."))
}
@Test
fun testRemoveNonExistentPropertyReturnsUnchanged() {
val content = """
|* Test Heading
|:PROPERTIES:
|:ID: test-id-123
|:END:
|Some body content.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.removeProperty(
content, headingPosition, "PINNED"
)
assertNotNull(result)
assertEquals(content, result)
}
@Test
fun testRemovePropertyNoDrawerReturnsUnchanged() {
val content = """
|* Test Heading
|Some body content.
""".trimMargin()
val headingPosition = 0
val result = PropertyDrawerUtils.removeProperty(
content, headingPosition, "PINNED"
)
assertNotNull(result)
assertEquals(content, result)
}
@Test
fun testRemovePropertyFromNestedHeading() {
val content = """
|* Parent Heading
|:PROPERTIES:
|:ID: parent-id
|:PINNED: t
|:END:
|Parent content.
|
|** Child Heading
|:PROPERTIES:
|:ID: child-id
|:PINNED: t
|:END:
|Child content.
""".trimMargin()
// Find position of child heading
val childPosition = content.indexOf("** Child Heading")
val result = PropertyDrawerUtils.removeProperty(
content, childPosition, "PINNED"
)
assertNotNull(result)
// Parent should still have PINNED
val parentSection = result.substring(0, result.indexOf("** Child Heading"))
assertTrue(parentSection.contains(":PINNED: t"))
// Child should not have PINNED
val childSection = result.substring(result.indexOf("** Child Heading"))
assertFalse(childSection.contains(":PINNED:"))
}
}ReviewDataDrawerUtilsTest
Tests REVIEW_DATA drawer format/create/parse utilities: formatReviewDataRow with all/null fields, createDrawer output format, setFileLevelReviewData (create new, position after PROPERTIES, update existing, update existing position), setHeadingLevelReviewData (create after PROPERTIES, create without properties, update existing, scope isolation), and multi-position integration for cloze and double-sided cards.
package computer.whatthefuck.arcology.editor
import computer.whatthefuck.arcology.flashcard.ReviewData
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Tests for ReviewDataDrawerUtils - REVIEW_DATA drawer manipulation.
*/
class ReviewDataDrawerUtilsTest {
// === formatReviewDataRow tests ===
@Test
fun testFormatReviewDataRow_withAllFields() {
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 3.5,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val row = ReviewDataDrawerUtils.formatReviewDataRow(reviewData)
assertTrue(row.contains("front"))
assertTrue(row.contains("2.5"))
assertTrue(row.contains("1"))
assertTrue(row.contains("3.5"))
assertTrue(row.contains("2025-01-15T10:00:00Z"))
}
@Test
fun testFormatReviewDataRow_withNullFields() {
val reviewData = ReviewData(
positionName = "back",
easeFactor = null,
box = null,
intervalDays = null,
dueDate = null
)
val row = ReviewDataDrawerUtils.formatReviewDataRow(reviewData)
assertTrue(row.contains("back"))
}
// === createDrawer tests ===
@Test
fun testCreateDrawer_createsCorrectFormat() {
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val drawer = ReviewDataDrawerUtils.createDrawer(reviewData)
assertTrue(drawer.startsWith(":REVIEW_DATA:"))
assertTrue(drawer.contains("| position | ease | box | interval | due |"))
assertTrue(drawer.contains("|----------+------+-----+----------+------|"))
assertTrue(drawer.contains("| front | 2.5 | 1 | 1.00 | 2025-01-15T10:00:00Z |"))
assertTrue(drawer.trimEnd().endsWith(":END:"))
}
// === setFileLevelReviewData tests ===
@Test
fun testSetFileLevelReviewData_createsNewDrawer() {
val content = """#+title: My Note
,* Some Heading
Content here.
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)
assertTrue(result.startsWith(":REVIEW_DATA:"))
assertTrue(result.contains(":END:"))
assertTrue(result.contains("front"))
assertTrue(result.contains("#+title: My Note"))
}
@Test
fun testSetFileLevelReviewData_createsDrawerAfterProperties() {
val content = """:PROPERTIES:
:ID: file-id-123
:END:
#+title: My Note
,* Some Heading
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)
// REVIEW_DATA drawer should come after PROPERTIES
val propsEndIdx = result.indexOf(":END:")
val reviewDataStartIdx = result.indexOf(":REVIEW_DATA:")
assertTrue(reviewDataStartIdx > propsEndIdx, "REVIEW_DATA should come after PROPERTIES drawer")
assertTrue(result.contains(":ID: file-id-123"))
}
@Test
fun testSetFileLevelReviewData_updatesExistingDrawer() {
val content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
| back | 2.0 | 2 | 5.0 | 2025-01-10T00:00:00Z |
:END:
#+title: My Note
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)
// Should have both positions now
assertTrue(result.contains("front"))
assertTrue(result.contains("back"))
assertTrue(result.contains("2.5")) // new ease
}
@Test
fun testSetFileLevelReviewData_updatesExistingPosition() {
val content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
| front | 2.0 | 2 | 5.0 | 2025-01-10T00:00:00Z |
:END:
#+title: My Note
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 3,
intervalDays = 10.0,
dueDate = Instant.parse("2025-01-20T12:00:00Z")
)
val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)
// Should update existing position
assertTrue(result.contains("front"))
assertTrue(result.contains("2.5"))
assertTrue(result.contains("3"))
assertTrue(result.contains("10.00"))
assertTrue(result.contains("2025-01-20T12:00:00Z"))
// Should NOT have old values
val frontLineIdx = result.indexOf("| front")
val frontLineEnd = result.indexOf('\n', frontLineIdx)
val frontLine = result.substring(frontLineIdx, frontLineEnd)
assertTrue(!frontLine.contains("2.0")) // old ease factor
}
// === setHeadingLevelReviewData tests ===
@Test
fun testSetHeadingLevelReviewData_createsNewDrawerAfterProperties() {
val content = """* Test Heading
:PROPERTIES:
:ID: heading-id-123
:END:
Some body content.
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)
assertNotNull(result)
// REVIEW_DATA should come after :END: of PROPERTIES
val propsEndIdx = result.indexOf(":END:")
val reviewDataStartIdx = result.indexOf(":REVIEW_DATA:")
assertTrue(reviewDataStartIdx > propsEndIdx, "REVIEW_DATA should come after PROPERTIES")
assertTrue(result.contains("front"))
assertTrue(result.contains("2.5"))
}
@Test
fun testSetHeadingLevelReviewData_createsDrawerWhenNoProperties() {
val content = """* Test Heading
Some body content.
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)
assertNotNull(result)
assertTrue(result.contains(":REVIEW_DATA:"))
assertTrue(result.contains("front"))
assertTrue(result.contains(":END:"))
}
@Test
fun testSetHeadingLevelReviewData_updatesExistingDrawer() {
val content = """* Test Heading
:PROPERTIES:
:ID: heading-id-123
:END:
:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
| back | 2.0 | 1 | 3.0 | 2025-01-10T00:00:00Z |
:END:
Some body content.
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)
assertNotNull(result)
// Should have both positions
assertTrue(result.contains("front"))
assertTrue(result.contains("back"))
assertTrue(result.contains("2.5")) // new position ease
}
@Test
fun testSetHeadingLevelReviewData_doesNotAffectOtherHeadings() {
val content = """* First Heading
:PROPERTIES:
:ID: first-id
:END:
First content.
,* Second Heading
:PROPERTIES:
:ID: second-id
:END:
Second content.
"""
val reviewData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)
assertNotNull(result)
// REVIEW_DATA should only be in the first heading
val firstReviewDataIdx = result.indexOf(":REVIEW_DATA:")
val secondHeadingIdx = result.indexOf("* Second Heading")
assertTrue(firstReviewDataIdx < secondHeadingIdx, "REVIEW_DATA should be in first heading")
// Verify only one REVIEW_DATA drawer
val reviewDataCount = result.split(":REVIEW_DATA:").size - 1
assertEquals(1, reviewDataCount, "Should only have one REVIEW_DATA drawer")
}
// === Integration tests with multiple positions ===
@Test
fun testMultiplePositions_clozeCard() {
var content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
:END:
"""
// Add first position
val reviewData0 = ReviewData(
positionName = "0",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
content = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData0)
// Add second position
val reviewData1 = ReviewData(
positionName = "1",
easeFactor = 2.6,
box = 2,
intervalDays = 3.0,
dueDate = Instant.parse("2025-01-17T10:00:00Z")
)
content = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData1)
// Verify both positions exist
assertTrue(content.contains("| 0 |"))
assertTrue(content.contains("| 1 |"))
assertTrue(content.contains("2.5"))
assertTrue(content.contains("2.6"))
}
@Test
fun testMultiplePositions_doubleSidedCard() {
var content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
:END:
"""
// Add front
val frontData = ReviewData(
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.parse("2025-01-15T10:00:00Z")
)
content = ReviewDataDrawerUtils.setFileLevelReviewData(content, frontData)
// Add back
val backData = ReviewData(
positionName = "back",
easeFactor = 2.8,
box = 2,
intervalDays = 5.0,
dueDate = Instant.parse("2025-01-20T10:00:00Z")
)
content = ReviewDataDrawerUtils.setFileLevelReviewData(content, backData)
// Verify both positions
assertTrue(content.contains("| front |"))
assertTrue(content.contains("| back |"))
assertTrue(content.contains("2.5"))
assertTrue(content.contains("2.8"))
}
}PlanningInfoUtilsTest
Tests PlanningInfoUtils — planning line insert/replace/remove (updatePlanningLine), reading (readPlanningLine), and repeater rescheduling (computeReschedule). Covers all three repeater types (EXACT +, CUMULATE ++, CATCH_UP .+), REPEAT_TO_STATE property override, precedence of SCHEDULED over DEADLINE, weekday/time preservation, and level-0 rejection.
package computer.whatthefuck.arcology.editor
import kotlinx.datetime.LocalDate
import xyz.lepisma.orgmode.Repeater
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse
class PlanningInfoUtilsTest {
// ============ updatePlanningLine tests ============
@Test
fun testInsertScheduledBeforePropertiesDrawer() {
val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body text.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat 14:00>")
assertNotNull(result)
val scheduledIdx = result.indexOf("SCHEDULED:")
val propsIdx = result.indexOf(":PROPERTIES:")
assertTrue(scheduledIdx > 0, "SCHEDULED should be present")
assertTrue(scheduledIdx < propsIdx, "SCHEDULED should come before :PROPERTIES:")
assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat 14:00>"))
}
@Test
fun testInsertScheduledNoPropertiesDrawer() {
val content = """* TODO Task
Body text.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat>")
assertNotNull(result)
assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat>"))
assertTrue(result.contains("Body text."))
}
@Test
fun testReplaceExistingScheduled() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat>")
assertNotNull(result)
assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat>"))
assertFalse(result.contains("SCHEDULED: <2026-07-01"))
}
@Test
fun testReplaceDeadlineLeavingScheduledIntact() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
DEADLINE: <2026-07-15 Tue>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.DEADLINE, "<2026-08-20 Fri>")
assertNotNull(result)
assertTrue(result.contains("SCHEDULED: <2026-07-01 Wed>"))
assertTrue(result.contains("DEADLINE: <2026-08-20 Fri>"))
assertFalse(result.contains("DEADLINE: <2026-07-15"))
}
@Test
fun testRemoveScheduled() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, null)
assertNotNull(result)
assertFalse(result.contains("SCHEDULED:"))
assertTrue(result.contains(":PROPERTIES:"))
assertTrue(result.contains(":ID: task-id"))
}
@Test
fun testRemoveNonExistentPlanningLineIsNoOp() {
val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.DEADLINE, null)
assertNotNull(result)
assertEquals(content, result)
}
@Test
fun testInsertOnHeadingWithTodoAndTags() {
val content = """* TODO Task :work:urgent:
Body.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.DEADLINE, "<2026-08-15 Sat>")
assertNotNull(result)
assertTrue(result.contains("* TODO Task :work:urgent:"))
assertTrue(result.contains("DEADLINE: <2026-08-15 Sat>"))
}
@Test
fun testInactiveTimestampPreserved() {
val content = """* Task
Body.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.CLOSED, "[2026-08-15 Sat]")
assertNotNull(result)
assertTrue(result.contains("CLOSED: [2026-08-15 Sat]"))
}
@Test
fun testRepeaterPreservedInString() {
val content = """* TODO Task
Body.
"""
val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat +1w>")
assertNotNull(result)
assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat +1w>"))
}
@Test
fun testRoundTripReadAfterWrite() {
val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val written = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat 14:00 +1d>")
assertNotNull(written)
val readBack = PlanningInfoUtils.readPlanningLine(written, 0, PlanningKind.SCHEDULED)
assertNotNull(readBack)
assertTrue(readBack.contains("SCHEDULED:"))
assertTrue(readBack.contains("<2026-08-15 Sat 14:00 +1d>"))
}
@Test
fun testReadNonExistentPlanningLineReturnsNull() {
val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val result = PlanningInfoUtils.readPlanningLine(content, 0, PlanningKind.SCHEDULED)
assertNull(result)
}
// ============ computeReschedule tests ============
@Test
fun testRescheduleExactRepeaterShiftsFromOriginal() {
// Original date 2026-07-01, today 2026-08-01, +1d EXACT
// Should shift to 2026-07-02 (backlog allowed)
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 8, 1)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertTrue(result.newContent.contains("<2026-07-02"), "EXACT should shift from original date")
assertEquals("TODO", result.newTodoState)
assertEquals(PlanningKind.SCHEDULED, result.rescheduledKind)
}
@Test
fun testRescheduleCumulateRepeaterShiftsFromToday() {
// Original date 2026-07-01, today 2026-08-01, ++1d CUMULATE
// Should shift to 2026-08-02 (from today)
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed ++1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 8, 1)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
println("DEBUG CUMULATE: result=$result")
assertNotNull(result)
assertTrue(result.newContent.contains("<2026-08-02"), "CUMULATE should shift from today")
}
@Test
fun testRescheduleCatchUpRepeaterSkipsPast() {
// Original date 2026-07-01, today 2026-08-01, .+1d CATCH_UP
// Should keep adding 1 day until >= 2026-08-01
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed .+1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 8, 1)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
// The shifted date should be >= today (2026-08-01)
assertTrue(result.newContent.contains("<2026-08-01") || result.newContent.contains("<2026-08-02"),
"CATCH_UP should land on or after today")
}
@Test
fun testRescheduleMonthlyRepeater() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1m>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertTrue(result.newContent.contains("<2026-08-01"), "Should shift by 1 month")
}
@Test
fun testRescheduleNoRepeaterReturnsNull() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNull(result)
}
@Test
fun testRescheduleRepeatToStateOverridesDefault() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:REPEAT_TO_STATE: NEXT
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertEquals("NEXT", result.newTodoState, "REPEAT_TO_STATE should override default TODO")
}
@Test
fun testRescheduleNoRepeatToStateDefaultsToTodo() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertEquals("TODO", result.newTodoState)
}
@Test
fun testRescheduleDefaultRepeatToStateParameterOverridesTodo() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today, defaultRepeatToState = "NEXT")
assertNotNull(result)
assertEquals("NEXT", result.newTodoState, "defaultRepeatToState param should override the TODO default")
}
@Test
fun testRescheduleRepeatToStatePropertyOverridesDefaultRepeatToStateParam() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:REPEAT_TO_STATE: WAITING
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today, defaultRepeatToState = "NEXT")
assertNotNull(result)
assertEquals("WAITING", result.newTodoState, "REPEAT_TO_STATE property should override the defaultRepeatToState param")
}
@Test
fun testRescheduleScheduledPrecedesDeadline() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
DEADLINE: <2026-07-10 Fri +1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertEquals(PlanningKind.SCHEDULED, result.rescheduledKind, "SCHEDULED should take precedence")
}
@Test
fun testRescheduleDeadlineOnlyWithRepeater() {
val content = """* TODO Task
DEADLINE: <2026-07-10 Fri +1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertEquals(PlanningKind.DEADLINE, result.rescheduledKind)
assertTrue(result.newContent.contains("<2026-07-17"), "Should shift deadline by 1 week")
}
@Test
fun testRescheduleNoPlanningInfoReturnsNull() {
val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNull(result)
}
@Test
fun testReschedulePreservesTimeOfDay() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed 09:00 +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertTrue(result.newContent.contains("09:00"), "Time-of-day should be preserved")
}
@Test
fun testRescheduleWeeklyRepeater() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertTrue(result.newContent.contains("<2026-07-08"), "Should shift by 1 week")
}
@Test
fun testRescheduleYearlyRepeater() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1y>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertTrue(result.newContent.contains("<2027-07-01"), "Should shift by 1 year")
}
@Test
fun testRescheduleCatchUpWeeklySkipsPast() {
// Original 2026-07-01, today 2026-08-01, .+1w CATCH_UP
// Should land on or after 2026-08-01
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed .+1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 8, 1)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
// Should have shifted forward by weeks until >= 2026-08-01
assertFalse(result.newContent.contains("<2026-07-0"), "Should not be in July")
}
@Test
fun testReschedulePreservesRepeaterInNewTimestamp() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 7, 15)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertTrue(result.newContent.contains("+1d>"), "Repeater should be preserved in new timestamp")
}
@Test
fun testRescheduleCumulateWeeklyFromToday() {
val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed ++1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
val today = LocalDate(2026, 8, 1)
val result = PlanningInfoUtils.computeReschedule(content, 0, today)
assertNotNull(result)
assertTrue(result.newContent.contains("<2026-08-08"), "CUMULATE weekly should be today + 1 week")
}
@Test
fun testAdvanceDateExactDoesNotJumpToToday() {
val original = LocalDate(2026, 7, 1)
val today = LocalDate(2026, 12, 1)
val repeater = Repeater.parse("+1d")!!
val result = PlanningInfoUtils.advanceDate(original, repeater, today)
assertEquals(LocalDate(2026, 7, 2), result, "EXACT shifts from original, not today")
}
@Test
fun testAdvanceDateCumulateJumpsFromToday() {
val original = LocalDate(2026, 7, 1)
val today = LocalDate(2026, 12, 1)
val repeater = Repeater.parse("++1m")!!
val result = PlanningInfoUtils.advanceDate(original, repeater, today)
assertEquals(LocalDate(2027, 1, 1), result, "CUMULATE shifts from today")
}
@Test
fun testFormatTimestampWithTimeAndRepeater() {
val ts = PlanningInfoUtils.formatTimestamp(
date = LocalDate(2026, 8, 15),
time = kotlinx.datetime.LocalTime(14, 30),
isActive = true,
repeater = "+1w",
showWeekday = true
)
// 2026-08-15 is a Saturday
assertEquals("<2026-08-15 Sat 14:30 +1w>", ts)
}
@Test
fun testFormatTimestampInactive() {
val ts = PlanningInfoUtils.formatTimestamp(
date = LocalDate(2026, 8, 15),
time = null,
isActive = false,
repeater = null,
showWeekday = true
)
assertEquals("[2026-08-15 Sat]", ts)
}
}Related Modules
indexer.org — File indexing service (used for re-indexing after edits)
models.org — Database repository and domain models
parser.org — Org-mode AST parser (used by PropertyDrawerUtils and NodeContentParser)
indexer-platform.org — File system abstraction (FileSystemInterface)