OrgDocumentEditorViewModel is the state machine behind OrgDocumentEditorScreen. It manages all editable state (TODO, tags, aliases, refs, location, flashcard, attachments, publish metadata) and coordinates save/refile/ID-creation operations through the editor package.
OrgDocumentEditorViewModel
The ViewModel supports two DocumentEditMode values:
NewCapture— creating a new note with optional template ID and subdirectoryEditExisting— editing an existing node by ID
And two EditorDisplayMode values:
ReadOnly— AST-rendered browsing with breadcrumbs, backlinksEditing— full text editing with metadata chips
Design: Why the whole thing is one ViewModel
Both new captures and existing node edits share most of their state (TODO state, tags, aliases, refs, properties drawer format). Splitting them into separate ViewModels would duplicate the rebuildHeadingLine / rebuildPropertiesDrawer machinery. The mode is distinguished by DocumentEditMode at construction time.
Design: Single text field for entire node
The primary state is _fullContent: MutableStateFlow<TextFieldValue>. This holds the complete org text:
Heading line (
* TODO Title :tags:)Properties drawer (
:PROPERTIES: ... :END:)Body content
Metadata chips (tags, aliases, refs, etc.) are "views" into this text. When a chip changes, the ViewModel rebuilds the relevant portion via rebuildHeadingLine() or rebuildPropertiesDrawer().
Design: Lazy AST parsing with cache
ParseState is a separate state flow that tracks the AST parse status. Parsing happens on Dispatchers.Default via OrgDocumentCache.parse(). Crucially, the parse is *gated on =displayMode == ReadOnly=* and debounced: EditingView never reads parseState, so reparsing on every keystroke was wasted work that grew linearly with document length and competed with the IME for CPU. The collector now fires only when the user switches to read-only view, or after a 300ms pause while already read-only.
Design: Level-0 (file-level) nodes
Unlike regular headings, file-level nodes (=level == 0=) have no heading line and use #+title: instead. The ViewModel has special handling for these throughout — loadFileContent, rebuildHeadingLine, saveExistingNode, and rebuildPropertiesDrawer all branch on =node.level==.
Design: Text surgery over structured edits
When saving an edit, the ViewModel extracts just the body content and calls editor.replaceHeadingBody(). This means the user can directly edit the heading line and properties drawer in the text field, and what they type is what gets saved. The metadata chips are helpers, not constraints. The document remains the source of truth.
Design: Regex-based heading parsing
Several methods (extractTodoFromHeading, extractTagsFromHeading, extractTitleFromHeading, extractPropertiesDrawer) use regex to parse org syntax. This is acknowledged technical debt — orgmode-kmp's OrgLexer would provide token position information that could replace these regex operations with byte-offset-based parsing.
State Management
The ViewModel manages the following state flows:
fullContent— the complete org text asTextFieldValueparseState— lazy AST parse result (IdleLoadingReady)editMode—DocumentEditMode(NewCaptureorEditExisting)displayMode—EditorDisplayMode(ReadOnlyorEditing)todoState,tags,aliases,refs— metadata extracted from heading/propertiescreateId— whether to generate an:ID:property on savegeoCoords— optionalGeoCoordinatefor location capturepublishEnabled,arcologyKey,arcologyExpire,arcologyAllowCrawl,arcologyPageTemplate— Arcology publishing metadata (see Publish)flashcardType,clozeType— flashcard configurationattachments— list ofPendingAttachmentqueued for savecaptureTemplates,selectedTemplateId,pendingPrompts— template expansion statenode— the currentOrgNodebeing edited (forEditExistingmode)parentNodes— breadcrumb hierarchy for read-only viewbacklinks,contentMatches— FTS results for backlinks panelexistingAttachments— attachments already saved to the nodeflashcardPositions,reviewHistory— flashcard review data for read-only panelbacklinksExpanded— collapsible panel stateeditorState—EditorState(LoadingEditingSavingSavedError)errorMessage— error message for display
Template Expansion
For NewCapture mode, templates are expanded via TemplateExpander:
%<...>timestamps are replaced with current time%^{prompt}placeholders triggerPromptDialogvia_pendingPrompts%cclipboard placeholders are noted but not implemented (no clipboard access)%llocation placeholders use currentgeoCoords
After %^{...} prompts are resolved, TemplateExpander.applyPromptResponses() substitutes them into the text.
Save Flow
Capture save (saveCapture)
Resolve or create daily file via
AndroidFileSystem/CaptureServiceBuild entry content from current state (
headingLine,properties,body)Append heading via
editor.appendHeading()Copy attachments via
handleAttachments()if entry has an IDCreate
REVIEW_DATAdrawer viacreateReviewDataForFlashcard()if flashcard type is set
Edit save (saveExistingNode)
Extract body content (after properties drawer)
Call
editor.replaceHeadingBody()to update the fileReset state on success
Refile Operations
Two refile flows exist:
refileToNode— refile the current node (from FAB)refileHeadingToNode— refile a heading within the current view (from heading long-press, for individual sections found byonHeadingRefileinReadOnlyView)
Both use editor.refileHeading() which handles the file-system surgery (finding heading, extracting content, removing from source, inserting under target).
ID Creation
createIdForHeading is called from the heading long-press dialog in ReadOnlyView when a heading lacks an :ID: property. It:
Gets the heading's byte position from the AST token range
Generates an ID via
CaptureService.generateId()Sets
:ID:property viaeditor.setPropertyByPosition()
Types & Enums
The ViewModel defines types used across its entire surface: document editing modes (DocumentEditMode with NewCapture and EditExisting variants), lifecycle state (EditorState), display mode (EditorDisplayMode), post-save actions (SaveAction), per-node metadata for the read-only renderer (PerNodeMetadata), and the lazy AST parse state (ParseState sealed class, shown alongside the class header since it is a nested type).
/**
,* Represents the mode of document editing.
,*/
sealed interface DocumentEditMode {
/**
,* Creating a new capture entry.
,* @param templateId Optional template ID to use
,* @param templateSubdirectory Optional subdirectory override for the capture
,* @param sharedText Text shared from another app (URL → refs, short → title, long → body)
,* @param sharedTitle Page title shared from another app (from EXTRA_SUBJECT)
,*/
data class NewCapture(
val templateId: String? = null,
val templateSubdirectory: String? = null,
val sharedText: String? = null,
val sharedTitle: String? = null
) : DocumentEditMode
/**
,* Editing an existing node.
,* @param nodeId The ID of the node to edit
,*/
data class EditExisting(val nodeId: String) : DocumentEditMode
}
/**
* Per-node metadata loaded eagerly from the database for all child nodes in a file.
* This allows the renderer to show tags/refs per-section without resorting to AST introspection.
*/
data class PerNodeMetadata(
val tags: List<String>,
val refs: List<String>
)
/**
* Editor lifecycle state used by the ViewModel's ~editorState~.
*/
enum class EditorState {
Loading, Editing, Saving, Saved, Error
}
/**
,* Represents the display mode for the editor.
,*/
enum class EditorDisplayMode {
/** Read-only view with breadcrumbs */
ReadOnly,
/** Full editing mode */
Editing
}
/**
*,* What to do after a save completes.
,*/
enum class SaveAction {
/** Switch to read-only view (default) */
OpenReadOnly,
/** Stay in editing mode */
ContinueEditing,
/** Switch to read-only and open refile picker */
Refile
}State Declarations
/**
,* ViewModel for unified org-mode document editing.
,* Handles both new captures and existing node editing through a single interface.
,*
,* Uses a single text field for the entire node content (heading line + properties + body),
,* allowing users to edit everything in one place.
,*/
@OptIn(FlowPreview::class)
class OrgDocumentEditorViewModel(
private val appPreferences: AppPreferencesInterface,
private val repository: RoamRepository,
private val quizRepository: QuizRepository,
private val nodeContentParser: NodeContentParser,
private val documentEditorFactory: (computer.whatthefuck.arcology.indexer.FileSystemInterface) -> OrgDocumentEditor,
mode: DocumentEditMode
) : ViewModel() {
// Internal state
private val _fullContent = MutableStateFlow(TextFieldValue())
val fullContent: StateFlow<TextFieldValue> = _fullContent
/**
,* Lazily-parsed AST for the current [fullContent] text.
,* Computed on [Dispatchers.Default] using the global [OrgDocumentCache]
,* so that navigating back to the same unmodified file is instant.
,*/
private val _parseState = MutableStateFlow<ParseState>(ParseState.Idle)
val parseState: StateFlow<ParseState> = _parseState
sealed class ParseState {
object Idle : ParseState()
object Loading : ParseState()
data class Ready(val result: OrgParseResult) : ParseState()
}
private val _editMode = MutableStateFlow(mode)
val editMode: StateFlow<DocumentEditMode> = _editMode
private val _todoState = MutableStateFlow<String?>(null)
val todoState: StateFlow<String?> = _todoState
private val _tags = MutableStateFlow<List<String>>(emptyList())
val tags: StateFlow<List<String>> = _tags
private val _aliases = MutableStateFlow<List<String>>(emptyList())
val aliases: StateFlow<List<String>> = _aliases
private val _refs = MutableStateFlow<List<String>>(emptyList())
val refs: StateFlow<List<String>> = _refs
private val _createId = MutableStateFlow(false)
val createId: StateFlow<Boolean> = _createId
private val _childMetadata = MutableStateFlow<Map<String, PerNodeMetadata>>(emptyMap())
val childMetadata: StateFlow<Map<String, PerNodeMetadata>> = _childMetadata
private val _geoCoords = MutableStateFlow<GeoCoordinate?>(null)
val geoCoords: StateFlow<GeoCoordinate?> = _geoCoords
// Planning timestamps: raw org timestamp string (e.g. "<2026-08-15 Fri 14:00 +1w>") or null
private val _scheduled = MutableStateFlow<String?>(null)
val scheduled: StateFlow<String?> = _scheduled
private val _deadline = MutableStateFlow<String?>(null)
val deadline: StateFlow<String?> = _deadline
// Flashcard type state
private val _flashcardType = MutableStateFlow<computer.whatthefuck.arcology.domain.FlashcardType?>(null)
val flashcardType: StateFlow<computer.whatthefuck.arcology.domain.FlashcardType?> = _flashcardType
private val _clozeType = MutableStateFlow(computer.whatthefuck.arcology.domain.ClozeType.DELETION)
val clozeType: StateFlow<computer.whatthefuck.arcology.domain.ClozeType> = _clozeType
private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
val attachments: StateFlow<List<PendingAttachment>> = _attachments
// Template-related state
private val _captureTemplates = MutableStateFlow(appPreferences.getCaptureTemplates())
val captureTemplates: StateFlow<List<CaptureTemplate>> = _captureTemplates.asStateFlow()
private val _selectedTemplateId = MutableStateFlow<String?>(null)
val selectedTemplateId: StateFlow<String?> = _selectedTemplateId.asStateFlow()
// TODO states from preferences
private val _todoStates = MutableStateFlow(appPreferences.getTodoStates())
val todoStates: StateFlow<List<String>> = _todoStates.asStateFlow()
private val _pendingPrompts = MutableStateFlow<List<PromptRequest>>(emptyList())
val pendingPrompts: StateFlow<List<PromptRequest>> = _pendingPrompts.asStateFlow()
private val _templateSubdirectory = MutableStateFlow<String?>(null)
val templateSubdirectory: StateFlow<String?> = _templateSubdirectory.asStateFlow()
// Display mode state
private val _displayMode = MutableStateFlow(EditorDisplayMode.ReadOnly)
val displayMode: StateFlow<EditorDisplayMode> = _displayMode
// State for existing node editing
private val _node = MutableStateFlow<OrgNode?>(null)
val node: StateFlow<OrgNode?> = _node
// Node level for refile check
val nodeLevel: StateFlow<Int> = _node.map { it?.level ?: 0 }.stateIn(
viewModelScope,
SharingStarted.Lazily,
0
)
// Parent nodes for breadcrumbs
private val _parentNodes = MutableStateFlow<List<BreadcrumbEntry>>(emptyList())
val parentNodes: StateFlow<List<BreadcrumbEntry>> = _parentNodes
// Backlinks state
private val _backlinks = MutableStateFlow<List<BacklinkItem>>(emptyList())
val backlinks: StateFlow<List<BacklinkItem>> = _backlinks
// Content matches state (FTS search for title mentions)
private val _contentMatches = MutableStateFlow<List<ContentMatchItem>>(emptyList())
val contentMatches: StateFlow<List<ContentMatchItem>> = _contentMatches
// Existing attachments (for EditExisting mode)
private val _existingAttachments = MutableStateFlow<List<computer.whatthefuck.arcology.domain.OrgAttachment>>(emptyList())
val existingAttachments: StateFlow<List<computer.whatthefuck.arcology.domain.OrgAttachment>> = _existingAttachments
// Flashcard positions (for read-only flashcard panel)
private val _flashcardPositions = MutableStateFlow<List<computer.whatthefuck.arcology.domain.FlashcardPosition>>(emptyList())
val flashcardPositions: StateFlow<List<computer.whatthefuck.arcology.domain.FlashcardPosition>> = _flashcardPositions
// Review history keyed by position name (for read-only flashcard panel)
private val _reviewHistory = MutableStateFlow<Map<String, List<computer.whatthefuck.arcology.domain.FlashcardReview>>>(emptyMap())
val reviewHistory: StateFlow<Map<String, List<computer.whatthefuck.arcology.domain.FlashcardReview>>> = _reviewHistory
// Backlinks panel expanded state
private val _backlinksExpanded = MutableStateFlow(false)
val backlinksExpanded: StateFlow<Boolean> = _backlinksExpanded
// Save completion events (use SharedFlow to avoid StateFlow conflation)
private val _saveCompleted = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val saveCompleted: SharedFlow<Unit> = _saveCompleted.asSharedFlow()
private val _editorState = MutableStateFlow(EditorState.Loading)
val editorState: StateFlow<EditorState> = _editorState
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage
// Track file system and file info for saving
private var _fileSystem: AndroidFileSystem? = null
val fileSystem: AndroidFileSystem? get() = _fileSystem
private var fileUri: String? = null
private var existingNodeId: String? = null
// Save action to perform after save completes
private val _saveAction = MutableStateFlow(SaveAction.OpenReadOnly)
val saveAction: StateFlow<SaveAction> = _saveAction.asStateFlow()
// Pending refile target triggered by save-and-refile action
private val _pendingRefileNodeId = MutableStateFlow<String?>(null)
val pendingRefileNodeId: StateFlow<String?> = _pendingRefileNodeId.asStateFlow()
// Arcology publishing metadata
private val _publishEnabled = MutableStateFlow(false)
val publishEnabled: StateFlow<Boolean> = _publishEnabled
private val _arcologyKey = MutableStateFlow("")
val arcologyKey: StateFlow<String> = _arcologyKey
// Inactive org timestamp string (e.g. "[2026-09-07 Mon 14:00]") or null
private val _arcologyExpire = MutableStateFlow<String?>(null)
val arcologyExpire: StateFlow<String?> = _arcologyExpire
private val _arcologyAllowCrawl = MutableStateFlow<Boolean?>(null)
val arcologyAllowCrawl: StateFlow<Boolean?> = _arcologyAllowCrawl
private val _arcologyPageTemplate = MutableStateFlow<String?>(null)
val arcologyPageTemplate: StateFlow<String?> = _arcologyPageTemplateInitialization
init {
// Keep templates in sync with preferences
viewModelScope.launch {
appPreferences.captureTemplates.collect { templates ->
_captureTemplates.value = templates
}
}
// Parse content off the main thread whenever fullContent changes, but only
// when the user is actually viewing the rendered (read-only) document.
// During editing the parsed AST is never read by the UI, so re-parsing on
// every keystroke is wasted work that grows linearly with document length
// and starves the IME/render thread. We debounce too, so a burst of typing
// collapses to a single parse after the user pauses.
viewModelScope.launch {
_displayMode.collect { mode ->
if (mode == EditorDisplayMode.ReadOnly) {
reparseContent()
}
}
}
viewModelScope.launch {
_fullContent
.debounce(300)
.collect { tfv ->
if (_displayMode.value == EditorDisplayMode.ReadOnly) {
reparseContent()
}
}
}
// Initialize based on mode
when (mode) {
is DocumentEditMode.NewCapture -> {
Log.d(
SHARE_DEBUG_TAG,
"NewCapture init: templateId=${mode.templateId} " +
"subdirectory=${mode.templateSubdirectory} " +
"sharedText=${mode.sharedText?.take(100)} " +
"sharedTitle=${mode.sharedTitle}"
)
// Set up initial content for capture
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val initialTitle = CaptureService.inactiveTimestamp(now)
_fullContent.value = TextFieldValue(text = initialTitle)
_createId.value = false
_editorState.value = EditorState.Editing
// Start in editing mode for new captures
_displayMode.value = EditorDisplayMode.Editing
// Apply template if specified
mode.templateId?.let { templateId ->
applyTemplate(templateId, mode.templateSubdirectory, mode.sharedTitle)
}
// Process shared text (URL → refs, short → title, long → body)
mode.sharedText?.let { sharedText ->
handleSharedText(sharedText)
}
Log.d(
SHARE_DEBUG_TAG,
"NewCapture final content head: ${_fullContent.value.text.take(200)}"
)
}
is DocumentEditMode.EditExisting -> {
// Load existing node content
loadExistingNode(mode.nodeId)
}
}
}
/**
,* Re-parse the current fullContent text and publish the result via _parseState.
,* Only meaningful when displayMode == ReadOnly — called when entering read-only
,* mode or after a debounced content change while already read-only. Runs the
,* lexer/parser on Dispatchers.Default so the UI thread is never blocked.
,*/
private suspend fun reparseContent() {
val text = _fullContent.value.text
if (text.isEmpty()) {
_parseState.value = ParseState.Idle
return
}
val path = fileUri ?: ""
_parseState.value = ParseState.Loading
val result = withContext(Dispatchers.Default) {
if (path.isNotBlank()) {
OrgDocumentCache.parse(path, text)
} else {
OrgDocumentCache.parse(text)
}
}
_parseState.value = ParseState.Ready(result)
}Edit Existing Node: Load from Database
private fun loadExistingNode(nodeId: String) {
viewModelScope.launch {
_editorState.value = EditorState.Loading
try {
val node = repository.getNodeById(nodeId)
if (node == null) {
_editorState.value = EditorState.Error
_errorMessage.value = "Node not found: $nodeId"
return@launch
}
existingNodeId = nodeId
_node.value = node
val treeUri = appPreferences.getSelectedDirectoryUri()
if (treeUri == null) {
_editorState.value = EditorState.Error
_errorMessage.value = "No directory selected. Configure one in Settings."
return@launch
}
fileUri = node.file
// Extract what we can from node data (metadata from database)
_todoState.value = node.todo
_geoCoords.value = node.properties[GeoCoordinate.PROPERTY_KEY]?.let { GeoCoordinate.parse(it) }
_scheduled.value = node.scheduled
_deadline.value = node.deadline
// Load tags from repository
try {
_tags.value = repository.getTagsByNode(nodeId)
} catch (e: Exception) {
Log.w(TAG, "Failed to load tags", e)
}
// Load refs from the refs table (canonical source)
try {
_refs.value = repository.getRefsByNode(nodeId).map { it.first }
} catch (e: Exception) {
Log.w(TAG, "Failed to load refs", e)
}
// Extract aliases from node properties
val roamAliases = node.properties["ROAM_ALIASES"] ?: ""
_aliases.value = parseRoamAliases(roamAliases)
// Seed Arcology publishing state from node properties
seedPublishStateFromProperties(node.properties)
// Eagerly load metadata for all child nodes in the same file
loadChildMetadata(node)
// Load parent nodes for breadcrumbs
loadParentNodes(node)
// Load backlinks, content matches, and attachments
loadBacklinks(nodeId)
loadContentMatches(node.title)
loadAttachments(nodeId)
loadFlashcardData(nodeId, node.properties)
// Rebuild full content from node data
// Body content is not in node properties - we'll load it from file when Context is available
val properties = node.properties.filterKeys { it !in setOf("ID", GeoCoordinate.PROPERTY_KEY) }
val hasProperties = properties.isNotEmpty()
val fullText = if (node.level == 0) {
// For level-0 nodes, use file-level format with #+TITLE: directive
// Tags are in #+FILETAGS (inherited from file), not in a heading line
buildString {
if (hasProperties) {
appendLine(":PROPERTIES:")
properties.forEach { (key, value) ->
appendLine(":$key: $value")
}
appendLine(":END:")
}
appendLine("#+title: ${node.title ?: ""}")
appendLine()
// Body content will be loaded from file when Context is available
}
} else {
// For regular headings, use the standard format
val todoPrefix = node.todo?.let { "$it " } ?: ""
val tagSuffix = if (_tags.value.isNotEmpty()) " :${_tags.value.joinToString(":")}:" else ""
val headingLine = "* $todoPrefix${node.title ?: ""}$tagSuffix"
buildString {
append(headingLine)
append("\n")
if (hasProperties) {
appendLine(":PROPERTIES:")
properties.forEach { (key, value) ->
appendLine(":$key: $value")
}
appendLine(":END:")
}
append("\n")
// Body content will be loaded from file when Context is available
}
}
_fullContent.value = TextFieldValue(text = fullText)
_editorState.value = EditorState.Editing
} catch (e: Exception) {
Log.e(TAG, "Failed to load existing node", e)
_editorState.value = EditorState.Error
_errorMessage.value = "Failed to load: ${e.message}"
}
}
}Edit Existing Node: Load Body from File System
/**
,* Load the body content from the org file.
,* This should be called when a file system is available (e.g., when the user opens the editor).
,*
,* @param fs The file system interface to read from (should be AndroidFileSystem)
,*/
fun loadFileContent(fs: computer.whatthefuck.arcology.indexer.FileSystemInterface) {
val nodeId = existingNodeId ?: return
val uri = fileUri ?: return
val node = _node.value ?: return
// Store the file system for attachment rendering
if (fs is AndroidFileSystem) {
_fileSystem = fs
}
viewModelScope.launch {
try {
// Create editor with the file system
val editor = documentEditorFactory(fs)
// Read the file content directly
val content = fs.readFile(uri)
// Find the heading boundaries
val boundaries = editor.findHeadingBoundaries(content, nodeId, node.level)
if (boundaries != null) {
val (headingPosition, nextHeadingPosition) = boundaries
if (node.level == 0) {
// For level-0 nodes, use the entire file content as-is
// Don't rebuild - just use the original content that was successfully indexed
_fullContent.value = TextFieldValue(text = content)
// Seed publish state from the file-level properties (DB node props cover this too)
seedPublishStateFromProperties(node.properties)
_editorState.value = EditorState.Editing
android.util.Log.d("OrgDocumentEditorVM", "Level-0 node: loaded ${content.length} chars directly from file")
} else {
// For regular headings, extract body and rebuild with heading line
val body = HeadingTextUtils.extractHeadingBody(
content, headingPosition, nextHeadingPosition
)
// Re-level child headings to match the current node's view level
// When viewing a level-N node, it's displayed as level 1.
// Children should be adjusted by offset (1 - node.level):
// - node.level = 1: offset = 0 (no change)
// - node.level = 2: offset = -1 (children at level 3 become level 2)
// - node.level = 3: offset = -2 (children at level 4 become level 2)
val levelOffset = 1 - node.level
val adjustedBody = HeadingTextUtils.adjustHeadingLevelsByOffset(body, levelOffset)
// Rebuild the full content with the heading line, properties, and adjusted body
val todoPrefix = _todoState.value?.let { "$it " } ?: ""
val tagSuffix = if (_tags.value.isNotEmpty()) " :${_tags.value.joinToString(":")}:" else ""
val headingLine = "* $todoPrefix${node.title ?: ""}$tagSuffix"
// Get properties from the heading section (not the entire file)
// For journal files, the file starts with file-level :PROPERTIES:,
// so we must extract from headingPosition to get the heading's drawer.
val sectionEnd = nextHeadingPosition ?: content.length
val headingSection = content.substring(headingPosition, sectionEnd)
val (loadedHeading, loadedProperties, loadedBody) = parseFileContent(headingSection, node.level)
// Update refs from the refs table (canonical source)
try {
_refs.value = repository.getRefsByNode(nodeId).map { it.first }
} catch (e: Exception) {
Log.w(TAG, "Failed to load refs from file", e)
}
// Update aliases from loaded properties
val newContent = buildString {
append(headingLine)
append("\n")
if (loadedProperties.isNotEmpty()) {
appendLine(":PROPERTIES:")
loadedProperties.forEach { (key, value) ->
appendLine(":$key: $value")
}
appendLine(":END:")
}
append("\n")
append(adjustedBody)
}
// Update full content with the complete node
_fullContent.value = TextFieldValue(text = newContent)
// Re-read planning lines from the freshly loaded file content
runCatching {
val sched = PlanningInfoUtils.readPlanningLine(newContent, 0, PlanningKind.SCHEDULED)
?.let { it.substringAfter("SCHEDULED: ").trim() }
val dl = PlanningInfoUtils.readPlanningLine(newContent, 0, PlanningKind.DEADLINE)
?.let { it.substringAfter("DEADLINE: ").trim() }
_scheduled.value = sched
_deadline.value = dl
}
// Seed publish state from the freshly parsed heading drawer
seedPublishStateFromProperties(loadedProperties)
_editorState.value = EditorState.Editing
}
} else {
_editorState.value = EditorState.Error
_errorMessage.value = "Could not find heading in file"
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load file content", e)
_editorState.value = EditorState.Error
_errorMessage.value = "Failed to load: ${e.message}"
}
}
}Parse Helpers
/**
,* Extract properties and body from file-level content (level-0 nodes).
,* File-level content has: :PROPERTIES: (optional), #+title:, then body.
,*/
private fun extractFileLevelPropertiesAndBody(content: String): Pair<Map<String, String>, String> {
val trimmed = content.trim()
// Check if content starts with :PROPERTIES:
if (!trimmed.startsWith(":PROPERTIES:")) {
// No properties drawer, just #+title: and body
val properties = emptyMap<String, String>()
val body = trimmed.lines()
.dropWhile { it.startsWith("#+") }
.joinToString("\n")
.trim()
return Pair(properties, body)
}
// Find the end of properties drawer
val endMarker = ":END:"
val endIdx = trimmed.indexOf(endMarker)
if (endIdx == -1) {
return Pair(emptyMap(), trimmed)
}
// Extract properties
val drawerContent = trimmed.substring(":PROPERTIES:".length, endIdx)
val properties = mutableMapOf<String, String>()
drawerContent.lines().forEach { line ->
val trimmedLine = line.trim()
// Property lines start with : and contain another : (e.g., :ID: value or :KEY: value)
if (trimmedLine.startsWith(":")) {
val colonIdx = trimmedLine.indexOf(':', 1)
// Valid property line has : at start, another : somewhere after, and a value after the 2nd :
if (colonIdx > 0 && colonIdx < trimmedLine.length - 1) {
val key = trimmedLine.substring(1, colonIdx).trim()
val value = trimmedLine.substring(colonIdx + 1).trim()
if (key.isNotEmpty()) {
properties[key] = value
}
}
}
}
// Extract body (after :END: and #+title: directive)
val afterDrawer = trimmed.substring(endIdx + endMarker.length).trim()
val body = afterDrawer.lines()
.dropWhile { it.startsWith("#+") || it.isEmpty() }
.joinToString("\n")
.trim()
return Pair(properties, body)
}
/**
,* Parse file content into heading line, properties map, and body.
,* Handles both simple headings and headings with property drawers.
,*/
private fun parseFileContent(content: String, nodeLevel: Int): Triple<String, Map<String, String>, String> {
if (nodeLevel == 0) {
// File-level node - no heading, just content
return Triple("", emptyMap(), content.trim())
}
// Find the first line (heading)
val newlinePos = content.indexOf('\n')
val headingLine = if (newlinePos != -1) content.substring(0, newlinePos) else content
// Find properties drawer
val afterHeading = if (newlinePos != -1) content.substring(newlinePos) else ""
val (properties, bodyStart) = extractPropertiesDrawer(afterHeading)
// Extract body
val body = if (bodyStart < afterHeading.length) {
afterHeading.substring(bodyStart).trim()
} else {
""
}
return Triple(headingLine, properties, body)
}
/**
,* Extract TODO state from heading line.
,*/
private fun extractTodoFromHeading(heading: String): String? {
// Pattern: * TODO Title :tags:
val match = Regex("\\*+\\s+(\\w+)").find(heading)
return match?.groupValues?.get(1)
}
/**
,* Extract tags from heading line.
,*/
private fun extractTagsFromHeading(heading: String): List<String> {
// Find :tag: at the end of heading
val match = Regex(":(\\S+):\\s*$").find(heading)
return if (match != null) {
match.groupValues[1].split(":").filter { it.isNotBlank() }
} else {
emptyList()
}
}
/**
,* Extract properties drawer from content.
,* Returns a map of properties and the index where the body starts.
,*/
private fun extractPropertiesDrawer(content: String): Pair<Map<String, String>, Int> {
val trimmed = content.trimStart()
if (!trimmed.startsWith(":PROPERTIES:")) {
return Pair(emptyMap(), 0)
}
val endMarker = ":END:"
val endIdx = content.indexOf(endMarker)
if (endIdx == -1) {
return Pair(emptyMap(), 0)
}
val drawerContent = content.substring(":PROPERTIES:".length, endIdx)
val properties = mutableMapOf<String, String>()
var bodyStart = endIdx + endMarker.length
drawerContent.lines().forEach { line ->
val trimmedLine = line.trim()
if (trimmedLine.startsWith(":") && trimmedLine.endsWith(":")) {
val colonIdx = trimmedLine.indexOf(':', 1)
if (colonIdx > 0 && colonIdx < trimmedLine.length - 1) {
val key = trimmedLine.substring(1, colonIdx).trim()
val value = trimmedLine.substring(colonIdx + 1).trim()
if (key.isNotEmpty()) {
properties[key] = value
}
}
}
}
// Find body start (after :END: and any newline)
if (bodyStart < content.length && content[bodyStart] == '\n') {
bodyStart++
}
if (bodyStart < content.length && content[bodyStart] == '\n') {
bodyStart++
}
return Pair(properties, bodyStart)
}Background Data Loading
/**
* Load parent nodes for breadcrumbs.
* Delegates to NodeContentParser which uses outlinePath to correctly include id-less headings.
*/
private fun loadChildMetadata(currentNode: OrgNode) {
viewModelScope.launch {
try {
Log.d(TAG, "loadChildMetadata: starting for node=${currentNode.id} file=${currentNode.file}")
val siblings = repository.getNodesByFile(currentNode.file)
Log.d(TAG, "loadChildMetadata: found ${siblings.size} siblings in file")
val childIds = siblings.map { it.id }.filter { it != currentNode.id && it.isNotBlank() }
Log.d(TAG, "loadChildMetadata: filtered to ${childIds.size} child IDs: $childIds")
if (childIds.isEmpty()) {
Log.d(TAG, "loadChildMetadata: no child IDs, returning early")
return@launch
}
val tagsMap = repository.getTagsByNodes(childIds)
val refsMap = repository.getRefsByNodes(childIds)
Log.d(TAG, "loadChildMetadata: tagsMap size=${tagsMap.size}, refsMap size=${refsMap.size}")
tagsMap.forEach { (nodeId, tags) ->
Log.d(TAG, "loadChildMetadata: node=$nodeId tags=$tags")
}
refsMap.forEach { (nodeId, refs) ->
Log.d(TAG, "loadChildMetadata: node=$nodeId refs=$refs")
}
val childIdsSet = childIds.toSet()
val metadata = childIdsSet.associateWith { id ->
PerNodeMetadata(
tags = tagsMap[id] ?: emptyList(),
refs = refsMap[id] ?: emptyList()
)
}
Log.d(TAG, "loadChildMetadata: created metadata map with ${metadata.size} entries")
_childMetadata.value = metadata
} catch (e: Exception) {
Log.w(TAG, "Failed to load child metadata", e)
}
}
}
/**
* Load parent nodes for breadcrumbs.
* Delegates to NodeContentParser which uses outlinePath to correctly include id-less headings.
*/
private fun loadParentNodes(currentNode: OrgNode) {
viewModelScope.launch {
try {
_parentNodes.value = nodeContentParser.getParentNodes(currentNode.id)
} catch (e: Exception) {
Log.w(TAG, "Failed to load parent nodes", e)
_parentNodes.value = emptyList()
}
}
}
/**
,* Load backlinks for the current node.
,* Backlinks are nodes that link TO this node.
,*/
private fun loadBacklinks(nodeId: String) {
viewModelScope.launch {
try {
val links = repository.getLinksTo(nodeId)
val backlinkItems = links.mapNotNull { link ->
repository.getNodeById(link.fromNode)?.let { node ->
BacklinkItem(
nodeId = node.id,
title = node.title ?: "Untitled",
file = node.file
)
}
}.distinctBy { it.nodeId }
_backlinks.value = backlinkItems
} catch (e: Exception) {
Log.w(TAG, "Failed to load backlinks", e)
_backlinks.value = emptyList()
}
}
}
/**
,* Load content matches for the node title.
,* Finds other nodes that mention this node's title in their content.
,*/
private fun loadContentMatches(nodeTitle: String?) {
if (nodeTitle.isNullOrBlank()) {
_contentMatches.value = emptyList()
return
}
viewModelScope.launch {
try {
val ftsResults = repository.searchNodesByContentBM25(nodeTitle, 20)
val currentNodeId = existingNodeId ?: return@launch
// Get backlink node IDs to exclude them from content matches
val backlinkNodeIds = _backlinks.value.map { it.nodeId }.toSet()
val matchItems = ftsResults
.filter { it.nodeId != currentNodeId && it.nodeId !in backlinkNodeIds }
.mapNotNull { result ->
repository.getNodeById(result.nodeId)?.let { node ->
ContentMatchItem(
nodeId = node.id,
title = node.title ?: "Untitled",
file = node.file,
rank = result.rank
)
}
}.distinctBy { it.nodeId }
_contentMatches.value = matchItems
} catch (e: Exception) {
Log.w(TAG, "Failed to load content matches", e)
_contentMatches.value = emptyList()
}
}
}
/**
,* Load attachments for the current node.
,*/
private fun loadAttachments(nodeId: String) {
viewModelScope.launch {
try {
_existingAttachments.value = repository.getAttachmentsByNode(nodeId)
} catch (e: Exception) {
Log.w(TAG, "Failed to load attachments", e)
_existingAttachments.value = emptyList()
}
}
}
/**
,* Load flashcard positions and review history for the read-only panel.
,* Only loads data if the node has FC_TYPE property (is a flashcard).
,*/
private fun loadFlashcardData(nodeId: String, properties: Map<String, String>) {
val fcType = properties["FC_TYPE"] ?: return
val type = FlashcardTypeMapper.mapFromString(fcType) ?: return
_flashcardType.value = type
viewModelScope.launch {
try {
val positions = quizRepository.getFlashcardPositions(nodeId)
_flashcardPositions.value = positions
val history = mutableMapOf<String, List<computer.whatthefuck.arcology.domain.FlashcardReview>>()
positions.forEach { position ->
val reviews = quizRepository.getFlashcardReviews(nodeId, position.positionName)
history[position.positionName] = reviews
}
_reviewHistory.value = history
} catch (e: Exception) {
Log.w(TAG, "Failed to load flashcard data", e)
}
}
}
/**
,* Map org-fc FC_TYPE string to FlashcardType enum.
,*/
private object FlashcardTypeMapper {
fun mapFromString(type: String): computer.whatthefuck.arcology.domain.FlashcardType? {
return when (type.lowercase()) {
"normal" -> computer.whatthefuck.arcology.domain.FlashcardType.NORMAL
"double" -> computer.whatthefuck.arcology.domain.FlashcardType.DOUBLE
"cloze" -> computer.whatthefuck.arcology.domain.FlashcardType.CLOZE
"text-input" -> computer.whatthefuck.arcology.domain.FlashcardType.TEXT_INPUT
"vocab" -> computer.whatthefuck.arcology.domain.FlashcardType.VOCAB
else -> null
}
}
}Template Expansion
/**
,* Apply a capture template to initialize the form.
,*/
private fun applyTemplate(templateId: String, subdirectory: String? = null, sharedTitle: String? = null) {
val template = appPreferences.getCaptureTemplate(templateId)
if (template == null) {
Log.w(
SHARE_DEBUG_TAG,
"applyTemplate: template '$templateId' NOT FOUND; persisted ids: " +
appPreferences.getCaptureTemplates().joinToString(",") { "${it.id}(${it.name})" }
)
return
}
Log.d(
SHARE_DEBUG_TAG,
"applyTemplate: id=$templateId titlePattern='${template.titlePattern}' " +
"bodyPattern='${template.bodyPattern.take(80)}' sharedTitle=$sharedTitle"
)
_selectedTemplateId.value = templateId
_templateSubdirectory.value = subdirectory
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
// Expand title pattern
val titleResult = TemplateExpander.expand(
template.titlePattern,
now,
clipboardProvider = { getClipboard() },
locationProvider = { geoCoords.value?.let { "${it.latitude},${it.longitude}" } },
pageTitleProvider = { sharedTitle }
)
Log.d(SHARE_DEBUG_TAG, "applyTemplate: expanded title='${titleResult.text.take(200)}'")
// Expand body pattern
val bodyResult = TemplateExpander.expand(
template.bodyPattern,
now,
clipboardProvider = { getClipboard() },
locationProvider = { geoCoords.value?.let { "${it.latitude},${it.longitude}" } },
pageTitleProvider = { sharedTitle }
)
// Collect all prompts
val allPrompts = (titleResult.prompts + bodyResult.prompts).distinctBy { it.label }
_pendingPrompts.value = allPrompts
// Build full content from expanded values
val todoPrefix = template.todoState?.let { "$it " } ?: ""
val tagSuffix = if (template.tags.isNotEmpty()) " :${template.tags.joinToString(":")}:" else ""
val fullText = buildString {
append("* $todoPrefix${titleResult.text}$tagSuffix")
append("\n")
// Build properties drawer if needed
val hasProperties = template.createId || geoCoords != null
if (hasProperties) {
appendLine(":PROPERTIES:")
if (template.createId) {
appendLine(":ID: <generated-on-save>")
}
appendLine(":END:")
}
append("\n")
append(bodyResult.text)
}
_fullContent.value = TextFieldValue(text = fullText)
_tags.value = template.tags
_todoState.value = template.todoState
_createId.value = template.createId
}
/**
,* Public method to select and apply a template.
,*/
fun selectTemplate(templateId: String) {
applyTemplate(templateId, null)
}
/**
,* Apply prompt responses to update the content.
,*/
fun resolvePrompts(responses: Map<String, String>) {
val currentText = _fullContent.value.text
val updatedText = TemplateExpander.applyPromptResponses(currentText, responses)
_fullContent.value = TextFieldValue(text = updatedText)
_pendingPrompts.value = emptyList()
}
/**
,* Clear template selection and reset to default state.
,*/
fun clearTemplate() {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val initialTitle = CaptureService.inactiveTimestamp(now)
_fullContent.value = TextFieldValue(text = initialTitle)
_selectedTemplateId.value = null
_pendingPrompts.value = emptyList()
_templateSubdirectory.value = null
}
/**
,* Get clipboard contents.
,*/
private fun getClipboard(): String? {
return null // TODO: Implement clipboard access if needed
}Shared Text Handling
/**
,* Handle shared text from a Share intent.
,* URLs go to refs, short text to title, long text to body.
,*/
fun handleSharedText(text: String) {
val trimmed = text.trim()
Log.d(
SHARE_DEBUG_TAG,
"handleSharedText: isUrl=${CaptureService.isUrl(trimmed)} len=${trimmed.length} text='${trimmed.take(100)}'"
)
if (CaptureService.isUrl(trimmed)) {
Log.d(SHARE_DEBUG_TAG, "handleSharedText: branch=URL→refs")
_refs.value = _refs.value + trimmed
_createId.value = true
rebuildPropertiesDrawer()
} else if (trimmed.length <= CaptureService.SHARE_TITLE_MAX_LENGTH) {
Log.d(SHARE_DEBUG_TAG, "handleSharedText: branch=short→title (currentText startsWith '[': ${_fullContent.value.text.startsWith("[")})")
val currentText = _fullContent.value.text
val parsedTitle = if (currentText.startsWith("[")) {
// Preserve timestamp prefix and append shared text
"$currentText$trimmed"
} else {
trimmed
}
_fullContent.value = TextFieldValue(text = parsedTitle)
} else {
Log.d(SHARE_DEBUG_TAG, "handleSharedText: branch=long→body")
// Long text becomes body: append to existing content
val currentText = _fullContent.value.text
_fullContent.value = TextFieldValue(text = "$currentText\n\n$trimmed")
}
}User Actions: Metadata Chips
The publish chip bundles the Arcology web server's publishing keywords behind one toggle. Turning it on implies createId (a published heading is a node), and the key field gates the optional fields: expire (inactive org timestamp), allow-crawl, and page template only appear once ARCOLOGY_KEY has a value. Toggling publish off removes only the :ARCOLOGY_KEY: property — expire/crawl/template state survives so re-publishing doesn't lose it. In EditExisting mode the drawer rebuild is unavailable (replaceHeadingBody only swaps the body), so persistence writes properties directly via editor.setProperty() / editor.removeProperty(), the same channel the PINNED toggle uses.
/**
,* Update the full content text field (when user types).
,*/
fun updateFullContent(value: TextFieldValue) {
_fullContent.value = value
}
/**
,* Update TODO state and rebuild heading line.
,*/
fun updateTodoState(state: String?) {
_todoState.value = state
rebuildHeadingLine()
}
/**
,* Update tags and rebuild heading line.
,*/
fun updateTags(tags: List<String>) {
_tags.value = tags
rebuildHeadingLine()
}
/**
,* Update aliases and rebuild properties drawer.
,*/
fun updateAliases(aliases: List<String>) {
_aliases.value = aliases
rebuildPropertiesDrawer()
}
/**
,* Update refs and rebuild properties drawer.
,*/
fun updateRefs(refs: List<String>) {
_refs.value = refs
rebuildPropertiesDrawer()
}
/**
,* Update location and rebuild properties drawer.
,*/
fun updateGeoCoords(coords: GeoCoordinate?) {
_geoCoords.value = coords
rebuildPropertiesDrawer()
}
/**
,* Update a planning timestamp (SCHEDULED or DEADLINE) in the edit buffer.
,* Pass null to remove the planning line. The heading is at offset 0 in the
,* single-node edit buffer (non-level-0 nodes only).
,*
,* @param kind Which planning keyword to set
,* @param timestampString Full org timestamp (e.g. "<2026-08-15 Fri 14:00 +1w>") or null to remove
,*/
fun updatePlanning(kind: PlanningKind, timestampString: String?) {
val node = _node.value
if (node?.level == 0) return
val currentText = _fullContent.value.text
val updated = PlanningInfoUtils.updatePlanningLine(currentText, 0, kind, timestampString)
if (updated != null) {
_fullContent.value = TextFieldValue(text = updated)
when (kind) {
PlanningKind.SCHEDULED -> _scheduled.value = timestampString
PlanningKind.DEADLINE -> _deadline.value = timestampString
PlanningKind.CLOSED -> {}
}
}
}
/**
,* Toggle ID creation.
,*/
fun updateCreateId(createId: Boolean) {
_createId.value = createId
rebuildPropertiesDrawer()
}User Actions: Publish
The publish toggle and its metadata fields. Enabling publish implies createId: a node published to the arcology web server needs an :ID: so backlinks and fediverse interactions can anchor to it. Disabling publish removes only :ARCOLOGY_KEY: — expire, crawl, and template preferences survive a publish/unpublish cycle.
In NewCapture mode these simply update state and rebuild the properties drawer. In EditExisting mode the drawer edits are persisted immediately through editor.setProperty() / editor.removeProperty() because saveExistingNode only replaces the heading body, never the drawer (see Edit Existing).
/**
,* Toggle Arcology publishing. Turning it on enables ID creation (a published
,* page is a node); turning it off removes only the ARCOLOGY_KEY value —
,* expire/crawl/template preferences are kept for re-publishing.
,*/
fun updatePublishEnabled(enabled: Boolean) {
_publishEnabled.value = enabled
if (enabled) {
if (!_createId.value) {
_createId.value = true
rebuildPropertiesDrawer()
}
} else if (_arcologyKey.value.isNotEmpty()) {
_arcologyKey.value = ""
rebuildPropertiesDrawer()
}
}
/**
,* Set the ARCOLOGY_KEY value (empty string disables the key but not the toggle).
,*/
fun updateArcologyKey(key: String) {
_arcologyKey.value = key.trim()
rebuildPropertiesDrawer()
}
/**
,* Set the ARCOLOGY_EXPIRE inactive timestamp, or null to remove it.
,*/
fun updateArcologyExpire(expire: String?) {
_arcologyExpire.value = expire
rebuildPropertiesDrawer()
}
/**
,* Set the ARCOLOGY_ALLOW_CRAWL value (true → "t", false → "nil", null → remove).
,*/
fun updateArcologyAllowCrawl(allowCrawl: Boolean?) {
_arcologyAllowCrawl.value = allowCrawl
rebuildPropertiesDrawer()
}
/**
,* Set the ARCOLOGY_PAGE_TEMPLATE value, or null to remove it.
,*/
fun updateArcologyPageTemplate(template: String?) {
_arcologyPageTemplate.value = template
rebuildPropertiesDrawer()
}
/**
,* Build a PublishMetadata from the current state, or null when publishing
,* is disabled or the key is blank.
,*/
private fun buildPublishMetadata(): PublishMetadata? {
if (!_publishEnabled.value || _arcologyKey.value.isBlank()) return null
return PublishMetadata(
key = _arcologyKey.value,
expire = _arcologyExpire.value,
allowCrawl = _arcologyAllowCrawl.value,
pageTemplate = _arcologyPageTemplate.value
)
}
/**
,* Seed publish state flows from a properties map (node or file level).
,*/
private fun seedPublishStateFromProperties(properties: Map<String, String>) {
val key = properties["ARCOLOGY_KEY"] ?: ""
if (key.isNotBlank()) {
_publishEnabled.value = true
_arcologyKey.value = key
_arcologyExpire.value = properties["ARCOLOGY_EXPIRE"]?.takeIf { it.isNotBlank() }
_arcologyAllowCrawl.value = properties["ARCOLOGY_ALLOW_CRAWL"]?.let { it == "t" }
_arcologyPageTemplate.value = properties["ARCOLOGY_PAGE_TEMPLATE"]?.takeIf { it.isNotBlank() }
}
}
/**
,* Persist publish properties for an existing node on save.
,* replaceHeadingBody only swaps the heading body, so drawer changes made
,* through the publish chip are written via setProperty/removeProperty.
,* Un-publishing removes only ARCOLOGY_KEY; expire/crawl/template survive.
,*/
private suspend fun persistArcologyPropertiesForExisting(editor: OrgDocumentEditor, nodeId: String) {
val key = _arcologyKey.value.trim()
if (_publishEnabled.value && key.isNotEmpty()) {
setExistingProperty(editor, nodeId, "ARCOLOGY_KEY", key)
_arcologyExpire.value?.takeIf { it.isNotBlank() }?.let {
setExistingProperty(editor, nodeId, "ARCOLOGY_EXPIRE", it)
}
_arcologyAllowCrawl.value?.let {
setExistingProperty(editor, nodeId, "ARCOLOGY_ALLOW_CRAWL", if (it) "t" else "nil")
}
_arcologyPageTemplate.value?.takeIf { it.isNotBlank() }?.let {
setExistingProperty(editor, nodeId, "ARCOLOGY_PAGE_TEMPLATE", it)
}
} else {
removeExistingProperty(editor, nodeId, "ARCOLOGY_KEY")
}
}
private suspend fun setExistingProperty(editor: OrgDocumentEditor, nodeId: String, key: String, value: String) {
val result = editor.setProperty(nodeId, key, value)
if (result is EditResult.Error) {
Log.w(TAG, "Failed to set $key: ${result.message}")
}
}
private suspend fun removeExistingProperty(editor: OrgDocumentEditor, nodeId: String, key: String) {
val result = editor.removeProperty(nodeId, key)
if (result is EditResult.Error) {
Log.w(TAG, "Failed to remove $key: ${result.message}")
}
}User Actions: Flashcard
/**
,* Update flashcard type.
,*/
fun updateFlashcardType(type: computer.whatthefuck.arcology.domain.FlashcardType?) {
_flashcardType.value = type
// If setting a flashcard type, automatically enable ID creation and add fc tag
if (type != null) {
_createId.value = true
if ("fc" !in _tags.value) {
_tags.value = _tags.value + "fc"
}
rebuildHeadingLine()
}
}
/**
,* Update cloze subtype.
,*/
fun updateClozeType(type: computer.whatthefuck.arcology.domain.ClozeType) {
_clozeType.value = type
}
/**
,* Cycle to the next flashcard type (null -> NORMAL -> DOUBLE -> CLOZE -> null).
,*/
fun cycleFlashcardType() {
val current = _flashcardType.value
val next = when (current) {
null -> computer.whatthefuck.arcology.domain.FlashcardType.NORMAL
computer.whatthefuck.arcology.domain.FlashcardType.NORMAL -> computer.whatthefuck.arcology.domain.FlashcardType.DOUBLE
computer.whatthefuck.arcology.domain.FlashcardType.DOUBLE -> computer.whatthefuck.arcology.domain.FlashcardType.CLOZE
else -> null
}
updateFlashcardType(next)
}User Actions: Attachments
/**
,* Add an attachment.
,*/
fun addAttachment(attachment: PendingAttachment) {
_attachments.value = _attachments.value + attachment
}
/**
,* Remove an attachment.
,*/
fun removeAttachment(uri: String) {
_attachments.value = _attachments.value.filter { it.uri != uri }
}Text Reconstruction
/**
,* Rebuild the heading line from title, TODO state, and tags.
,* Preserves the body content.
,* Note: For level-0 nodes, use rebuildPropertiesDrawer instead since level-0 has no heading line.
,*/
fun rebuildHeadingLine() {
val node = _node.value
if (node?.level == 0) {
// Level-0 nodes don't have heading lines - use rebuildPropertiesDrawer instead
rebuildPropertiesDrawer()
return
}
val currentText = _fullContent.value.text
val (headingLine, body) = splitHeadingAndBody(currentText)
val todoPrefix = _todoState.value?.let { "$it " } ?: ""
val tagSuffix = if (_tags.value.isNotEmpty()) " :${_tags.value.joinToString(":")}:" else ""
val newHeadingLine = "* $todoPrefix${extractTitleFromHeading(headingLine)}$tagSuffix"
// body already contains everything after the heading line (including the newline if present)
// so we just append it directly
val newContent = newHeadingLine + body
_fullContent.value = TextFieldValue(text = newContent)
}
/**
,* Split content into heading line and body.
,*/
private fun splitHeadingAndBody(content: String): Pair<String, String> {
val newlinePos = content.indexOf('\n')
val heading = if (newlinePos != -1) content.substring(0, newlinePos) else content
val body = if (newlinePos != -1) content.substring(newlinePos) else ""
return Pair(heading, body)
}
/**
,* Strip properties drawer from body content.
,* Returns the body content without any :PROPERTIES: drawer.
,*/
private fun stripPropertiesDrawer(body: String): String {
// Match :PROPERTIES: ... :END: pattern
val propertiesPattern = Regex(":PROPERTIES:[\\s\\S]*?:END:\\s*\n?")
return propertiesPattern.replace(body.trimStart(), "").trim()
}
/**
,* Extract title from heading line.
,* Tags in org-mode are in the format :tagname: and appear at the end of the heading.
,* This function finds the last tag pattern to properly handle titles containing colons
,* (like timestamps [2026-03-15 Sun 12:00]).
,*/
private fun extractTitleFromHeading(heading: String): String {
// Remove leading * and whitespace
val afterStars = heading.trimStart('*').trimStart()
// Find the last tag in the format :tagname: at the end of the heading
// Tags must start with : and end with :, and be separated by spaces from content
val tagPattern = Regex(":[a-zA-Z][a-zA-Z0-9_-]*:$")
val match = tagPattern.find(afterStars)
return if (match != null) {
// Extract title (everything before the last tag)
afterStars.substring(0, match.range.first).trim()
} else {
afterStars
}
}
/**
,* Rebuild the properties drawer from current state.
,*/
fun rebuildPropertiesDrawer() {
val currentText = _fullContent.value.text
val node = _node.value
val isLevel0 = node?.level == 0
val publishKey = if (_publishEnabled.value) _arcologyKey.value.takeIf { it.isNotBlank() } else null
val hasProperties = _createId.value ||
_aliases.value.isNotEmpty() ||
_refs.value.isNotEmpty() ||
_geoCoords.value != null ||
_attachments.value.isNotEmpty() ||
publishKey != null
val newContent = if (hasProperties) {
if (isLevel0) {
// For level-0: :PROPERTIES: + #+title: + body
// Strip any existing properties drawer from the content first
val body = extractBodyFromLevel0Content(currentText)
buildString {
appendLine(":PROPERTIES:")
if (_createId.value) {
appendLine(":ID: <generated-on-save>")
}
if (_aliases.value.isNotEmpty()) {
appendLine(":ROAM_ALIASES: ${_aliases.value.joinToString(" ") { "\"$it\"" }}")
}
if (_refs.value.isNotEmpty()) {
appendLine(":ROAM_REFS: ${_refs.value.joinToString(" ") { "\"$it\"" }}")
}
if (_geoCoords.value != null) {
val coords = _geoCoords.value!!
appendLine(":GEO_COORDS: ${coords.latitude},${coords.longitude}")
}
if (publishKey != null) {
appendLine(":ARCOLOGY_KEY: $publishKey")
_arcologyExpire.value?.let { appendLine(":ARCOLOGY_EXPIRE: $it") }
_arcologyAllowCrawl.value?.let { crawl ->
appendLine(":ARCOLOGY_ALLOW_CRAWL: ${if (crawl) "t" else "nil"}")
}
_arcologyPageTemplate.value?.let { appendLine(":ARCOLOGY_PAGE_TEMPLATE: $it") }
}
appendLine(":END:")
appendLine("#+title: ${node?.title ?: ""}")
append("\n")
append(body.trim())
}
} else {
// For regular headings: heading + :PROPERTIES: + body
val (heading, body) = splitHeadingAndBody(currentText)
// Strip any existing properties drawer from the body before rebuilding
val cleanBody = stripPropertiesDrawer(body)
buildString {
append(heading)
append("\n")
appendLine(":PROPERTIES:")
if (_createId.value) {
appendLine(":ID: <generated-on-save>")
}
if (_aliases.value.isNotEmpty()) {
appendLine(":ROAM_ALIASES: ${_aliases.value.joinToString(" ") { "\"$it\"" }}")
}
if (_refs.value.isNotEmpty()) {
appendLine(":ROAM_REFS: ${_refs.value.joinToString(" ") { "\"$it\"" }}")
}
if (_geoCoords.value != null) {
val coords = _geoCoords.value!!
appendLine(":GEO_COORDS: ${coords.latitude},${coords.longitude}")
}
if (publishKey != null) {
appendLine(":ARCOLOGY_KEY: $publishKey")
_arcologyExpire.value?.let { appendLine(":ARCOLOGY_EXPIRE: $it") }
_arcologyAllowCrawl.value?.let { crawl ->
appendLine(":ARCOLOGY_ALLOW_CRAWL: ${if (crawl) "t" else "nil"}")
}
_arcologyPageTemplate.value?.let { appendLine(":ARCOLOGY_PAGE_TEMPLATE: $it") }
}
appendLine(":END:")
append("\n")
append(cleanBody.trim())
}
}
} else {
// No properties
if (isLevel0) {
// For level-0: just #+title: + body (no heading line, no properties drawer)
val body = extractBodyFromLevel0Content(currentText)
buildString {
appendLine("#+title: ${node?.title ?: ""}")
append("\n")
append(body.trim())
}
} else {
// For regular headings: just heading + body (no properties drawer)
val (heading, body) = splitHeadingAndBody(currentText)
// Strip any existing properties drawer from the body
val cleanBody = stripPropertiesDrawer(body)
buildString {
append(heading)
append("\n")
append(cleanBody.trim())
}
}
}
_fullContent.value = TextFieldValue(text = newContent)
}
/**
,* Extract body content from level-0 file content.
,* Skips :PROPERTIES: drawer and #+title: directive.
,*/
private fun extractBodyFromLevel0Content(content: String): String {
val lines = content.lines()
var bodyStart = 0
// Skip :PROPERTIES: drawer if present
if (lines.isNotEmpty() && lines[0].trim() == ":PROPERTIES:") {
// Find :END:
var inDrawer = true
for (i in 1 until lines.size) {
if (lines[i].trim() == ":END:") {
bodyStart = i + 1
inDrawer = false
break
}
}
if (inDrawer) return "" // No :END: found
}
// Skip #+title: directive
while (bodyStart < lines.size && lines[bodyStart].trim().startsWith("#+title:")) {
bodyStart++
}
// Skip empty lines after title
while (bodyStart < lines.size && lines[bodyStart].trim().isEmpty()) {
bodyStart++
}
return lines.drop(bodyStart).joinToString("\n")
}Save: Entry Point
/**
,* Save the document (capture or update).
,*/
fun save(context: Context, action: SaveAction = SaveAction.OpenReadOnly) {
_saveAction.value = action
val treeUri = appPreferences.getSelectedDirectoryUri()
if (treeUri == null) {
_errorMessage.value = "No directory selected. Configure one in Settings."
return
}
_editorState.value = EditorState.Saving
viewModelScope.launch {
try {
val fs = AndroidFileSystem(context, treeUri)
_fileSystem = fs
val editor = documentEditorFactory(fs)
when (val mode = _editMode.value) {
is DocumentEditMode.NewCapture -> {
saveCapture(fs, editor, mode)
}
is DocumentEditMode.EditExisting -> {
saveExistingNode(editor, mode.nodeId)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to save", e)
_editorState.value = EditorState.Error
_errorMessage.value = e.message ?: "Unknown error"
}
}
}Save: Capture
private suspend fun saveCapture(
fs: AndroidFileSystem,
editor: OrgDocumentEditor,
mode: DocumentEditMode.NewCapture
) {
val rootDocId = fs.getRootDocumentId()
val captureDir = mode.templateSubdirectory ?: appPreferences.getCaptureSubdirectory()
val dailyDocId = fs.findChildByName(rootDocId, captureDir)
?: fs.createDirectory(rootDocId, captureDir)
val nowInstant = Clock.System.now()
val now = nowInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val micros = (nowInstant.nanosecondsOfSecond / 1000)
// Find or create today's file
val fileName = CaptureService.dailyFileName(now.date)
val existingFileDocId = fs.findChildByName(dailyDocId, fileName)
val fileUri = existingFileDocId?.let { fs.buildDocumentUri(it) }
?: fs.createFile(dailyDocId, fileName).also { uri ->
val header = CaptureService.buildDailyFileContent(
now.date,
CaptureService.generateId(now, micros)
)
fs.writeFile(uri, header)
}
this.fileUri = fileUri
// Build the capture entry
val fullContent = _fullContent.value.text
val publish = buildPublishMetadata()
val shouldCreateId = _createId.value || _refs.value.isNotEmpty() || _aliases.value.isNotEmpty() || _attachments.value.isNotEmpty() || publish != null
val contentLines = fullContent.lines()
val headingLine = contentLines.firstOrNull { it.startsWith("*") } ?: ""
// Parse TODO state
val todoMatch = Regex("\\*+\\s+(\\w+)").find(headingLine)
val todoState = todoMatch?.groupValues?.get(1)?.takeIf { it in setOf("TODO", "DOING", "DONE", "WAITING", "CANCELLED") }
// Parse title (everything between TODO and tags)
val title = extractTitleFromHeading(headingLine)
// Parse tags
val tags = _tags.value
// Parse aliases, refs from properties
val aliases = _aliases.value
val refs = _refs.value
val geoCoords = _geoCoords.value
val entryId = if (shouldCreateId) {
val entryInstant = Clock.System.now()
val entryDt = entryInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val entryMicros = (entryInstant.nanosecondsOfSecond / 1000)
CaptureService.generateId(entryDt, entryMicros)
} else null
// Build entry using CaptureService
// Extract body: skip blank lines, TODO/DONE keywords, and the heading line
// but preserve sub-headings (lines starting with *)
val rawBody = contentLines.dropWhile { it.isBlank() || (it.startsWith("*") && it.count { c -> c == '*' } == 1) }.joinToString("\n").trim()
// Strip any :PROPERTIES: drawer that was inserted by rebuildPropertiesDrawer() during editing
// buildCaptureEntry() will create its own drawer with the final properties
val body = stripPropertiesDrawer(rawBody)
val flashcardType = _flashcardType.value
val clozeType = if (flashcardType == computer.whatthefuck.arcology.domain.FlashcardType.CLOZE) _clozeType.value else null
val entry = CaptureService.buildCaptureEntry(
body = body,
title = title.takeIf { it.isNotBlank() },
tags = tags,
aliases = aliases,
refs = refs,
todoState = todoState,
id = entryId,
time = now.time,
geoCoords = geoCoords,
flashcardType = flashcardType,
clozeType = clozeType,
publish = publish
)
// Append to file
val appendResult = editor.appendHeading(fileUri, entry)
when (appendResult) {
is EditResult.Success -> {
// Handle attachments if created
if (entryId != null && _attachments.value.isNotEmpty()) {
handleAttachments(fs, entryId)
}
// Create REVIEW_DATA drawer for flashcards
if (entryId != null && flashcardType != null) {
createReviewDataForFlashcard(editor, entryId, flashcardType, body)
}
_editorState.value = EditorState.Saved
_saveCompleted.tryEmit(Unit)
// Don't clear template here - resetState() will re-apply it
resetState(_editMode.value)
}
is EditResult.Error -> {
Log.e(TAG, "Failed to append: ${appendResult.message}")
_editorState.value = EditorState.Error
_errorMessage.value = "Failed to save: ${appendResult.message}"
}
}
}Save: Edit Existing
private suspend fun saveExistingNode(editor: OrgDocumentEditor, nodeId: String) {
val content = _fullContent.value.text
val node = _node.value ?: return
Log.d(TAG, "saveExistingNode: START - nodeId=$nodeId, node.title=${node.title}, node.level=${node.level}, node.file=${node.file}")
Log.d(TAG, "saveExistingNode: fullContent.length=${content.length}")
// For editing, we just update the body
// The full content includes heading and properties which users edit directly
// We need to extract just the body for replaceHeadingBody
val bodyContent = if (node?.level == 0) {
// For level-0 (file-level) nodes, extract body after properties and title
val body = extractBodyFromLevel0Content(content)
Log.d(TAG, "saveExistingNode: level-0 node, body.length=${body.length}")
body
} else {
// For regular headings, use the existing logic
val (heading, body) = splitHeadingAndBody(content)
Log.d(TAG, "saveExistingNode: extracted heading='$heading', body.length=${body.length}")
if (body.startsWith(":PROPERTIES:")) {
// Find body after :END:
val endMarker = ":END:"
val endIdx = body.indexOf(endMarker)
if (endIdx != -1) {
val result = body.substring(endIdx + endMarker.length).trim()
Log.d(TAG, "saveExistingNode: body after :END:, result.length=${result.length}")
result
} else {
Log.d(TAG, "saveExistingNode: no :END: marker found")
""
}
} else {
val result = body.trim()
Log.d(TAG, "saveExistingNode: trimmed body, result.length=${result.length}")
result
}
}
Log.d(TAG, "saveExistingNode: calling replaceHeadingBody with bodyContent.length=${bodyContent.length}")
val result = editor.replaceHeadingBody(nodeId, bodyContent)
when (result) {
is EditResult.Success -> {
// The publish chip edits the drawers in the buffer, but
// replaceHeadingBody only persists the body — write the
// ARCOLOGY_* properties directly to the file.
persistArcologyPropertiesForExisting(editor, nodeId)
_editorState.value = EditorState.Saved
_saveCompleted.tryEmit(Unit)
// Reset state after successful edit
resetState(_editMode.value)
}
is EditResult.Error -> {
Log.e(TAG, "Failed to save: ${result.message}")
_editorState.value = EditorState.Error
_errorMessage.value = "Failed to save: ${result.message}"
}
}
}Save: Reset State
/**
,* Reset the form state after successful save.
,* If a template was selected, re-apply it; otherwise reset to blank timestamp.
,*/
fun resetState(mode: DocumentEditMode? = null) {
val action = _saveAction.value
if (mode is DocumentEditMode.EditExisting) {
when (action) {
SaveAction.OpenReadOnly -> {
_displayMode.value = EditorDisplayMode.ReadOnly
_editorState.value = EditorState.Editing
_errorMessage.value = null
return
}
SaveAction.ContinueEditing -> {
_editorState.value = EditorState.Editing
_errorMessage.value = null
return
}
SaveAction.Refile -> {
_displayMode.value = EditorDisplayMode.ReadOnly
_pendingRefileNodeId.value = _node.value?.id
_editorState.value = EditorState.Editing
_errorMessage.value = null
return
}
}
}
_saveAction.value = SaveAction.OpenReadOnly
val previouslySelectedTemplateId = _selectedTemplateId.value
_todoState.value = null
_tags.value = emptyList()
_aliases.value = emptyList()
_refs.value = emptyList()
_createId.value = false
_geoCoords.value = null
_publishEnabled.value = false
_arcologyKey.value = ""
_arcologyExpire.value = null
_arcologyAllowCrawl.value = null
_arcologyPageTemplate.value = null
_flashcardType.value = null
_clozeType.value = computer.whatthefuck.arcology.domain.ClozeType.DELETION
_attachments.value = emptyList()
_pendingPrompts.value = emptyList()
_templateSubdirectory.value = null
_editorState.value = EditorState.Editing
_errorMessage.value = null
if (previouslySelectedTemplateId != null) {
// Clear the template ID first so it doesn't get re-applied again
_selectedTemplateId.value = null
applyTemplate(previouslySelectedTemplateId, null)
} else {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val initialTitle = CaptureService.inactiveTimestamp(now)
_fullContent.value = TextFieldValue(text = initialTitle)
}
}Save: Post-Processing
/**
,* Create REVIEW_DATA drawer for a newly captured flashcard.
,*/
private suspend fun createReviewDataForFlashcard(
editor: OrgDocumentEditor,
nodeId: String,
flashcardType: computer.whatthefuck.arcology.domain.FlashcardType,
body: String
) {
val now = Clock.System.now()
val positionNames = when (flashcardType) {
computer.whatthefuck.arcology.domain.FlashcardType.NORMAL -> listOf("front")
computer.whatthefuck.arcology.domain.FlashcardType.DOUBLE -> listOf("front", "back")
computer.whatthefuck.arcology.domain.FlashcardType.CLOZE -> {
val maxHoleId = ClozeService().findMaxHoleId(body)
if (maxHoleId >= 0) {
(0..maxHoleId).map { it.toString() }
} else {
listOf("0")
}
}
computer.whatthefuck.arcology.domain.FlashcardType.TEXT_INPUT -> listOf("front")
computer.whatthefuck.arcology.domain.FlashcardType.VOCAB -> listOf("front")
}
positionNames.forEach { position ->
val reviewData = ReviewData(
positionName = position,
easeFactor = SM2Algorithm.DEFAULT_EASE_FACTOR,
box = 0,
intervalDays = 0.0,
dueDate = now,
reviewCount = 0
)
val result = editor.setReviewData(nodeId, position, reviewData)
if (result is EditResult.Error) {
Log.w(TAG, "Failed to set review data for position $position: ${result.message}")
}
}
}
private suspend fun handleAttachments(fs: AndroidFileSystem, entryId: String) {
try {
val rootDocId = fs.getRootDocumentId()
val captureDir = appPreferences.getCaptureSubdirectory()
val dailyDocId = fs.findChildByName(rootDocId, captureDir)
?: fs.createDirectory(rootDocId, captureDir)
val dataDocId = fs.findChildByName(dailyDocId, "data")
?: fs.createDirectory(dailyDocId, "data")
val subdirId = entryId.take(6)
val subdirDocId = fs.findChildByName(dataDocId, subdirId)
?: fs.createDirectory(dataDocId, subdirId)
val targetDirDocId = fs.findChildByName(subdirDocId, entryId.drop(6))
?: fs.createDirectory(subdirDocId, entryId.drop(6))
_attachments.value.forEach { attachment ->
val sourceUri = android.net.Uri.parse(attachment.uri)
fs.copyFileToTree(sourceUri, targetDirDocId, attachment.filename)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to copy attachments", e)
}
}Refile: Node
/**
,* Clear the pending refile node ID after it has been consumed.
,*/
fun clearPendingRefile() {
_pendingRefileNodeId.value = null
}
/**
,* Check if the current node can be refiled.
,* Level-0 nodes (file-level) cannot be refiled.
,*/
fun canRefile(): Boolean = _node.value?.level ?: 0 > 0
/**
,* Refile the current node to a target node.
,*
,* @param context Android context for file operations
,* @param targetNode The node to refile under
,* @param onSuccess Callback when refile completes successfully
,*/
fun refileToNode(context: Context, targetNode: OrgNode, onSuccess: () -> Unit) {
val currentNode = _node.value ?: return
val treeUri = appPreferences.getSelectedDirectoryUri() ?: return
// Cannot refile level-0 nodes
if (currentNode.level == 0) {
_errorMessage.value = "Cannot refile file-level nodes"
return
}
// Cannot refile to self
if (targetNode.id == currentNode.id) {
_errorMessage.value = "Cannot refile to the same node"
return
}
viewModelScope.launch {
try {
val fs = AndroidFileSystem(context, treeUri)
val editor = documentEditorFactory(fs)
val result = editor.refileHeading(currentNode.id, targetNode.id)
when (result) {
is EditResult.Success -> {
onSuccess()
}
is EditResult.Error -> {
_errorMessage.value = result.message
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to refile node", e)
_errorMessage.value = "Failed to refile: ${e.message}"
}
}
}Refile: Heading
fun refileHeadingToNode(context: Context, headingNodeId: String, targetNode: OrgNode, onSuccess: () -> Unit) {
val treeUri = appPreferences.getSelectedDirectoryUri() ?: return
viewModelScope.launch {
try {
val fs = AndroidFileSystem(context, treeUri)
val editor = documentEditorFactory(fs)
val result = editor.refileHeading(headingNodeId, targetNode.id)
when (result) {
is EditResult.Success -> {
onSuccess()
}
is EditResult.Error -> {
_errorMessage.value = result.message
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to refile heading", e)
_errorMessage.value = "Failed to refile: ${e.message}"
}
}
}ID Creation
/**
,* Create an ID property for a heading that doesn't have one.
,* Reads the file content, generates an ID, inserts it into the heading's
,* property drawer (creating one if needed), and writes back + re-indexes.
,*
,* @param section The org section whose heading needs an ID
,* @param onSuccess Callback when the ID is successfully created
,*/
fun createIdForHeading(
section: xyz.lepisma.orgmode.OrgSection,
context: Context,
onSuccess: () -> Unit
) {
val treeUri = appPreferences.getSelectedDirectoryUri() ?: return
val uri = fileUri ?: return
viewModelScope.launch {
try {
val fs = AndroidFileSystem(context, treeUri)
val headingTokens = section.heading.tokens
if (headingTokens.isEmpty()) {
_errorMessage.value = "Could not find heading position"
return@launch
}
val headingPosition = headingTokens.first().range.first
val nowInstant = Clock.System.now()
val now = nowInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val micros = (nowInstant.nanosecondsOfSecond / 1000)
val newId = CaptureService.generateId(now, micros)
val editor = documentEditorFactory(fs)
val result = editor.setPropertyByPosition(uri, headingPosition, "ID", newId)
when (result) {
is EditResult.Success -> {
onSuccess()
}
is EditResult.Error -> {
_errorMessage.value = result.message
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create ID for heading", e)
_errorMessage.value = "Failed to create ID: ${e.message}"
}
}
}UI Helpers
/**
,* Toggle backlinks panel expanded state.
,*/
fun toggleBacklinksExpanded() {
_backlinksExpanded.value = !_backlinksExpanded.value
}
/**
,* Switch to editing mode.
,*/
fun enterEditingMode() {
_displayMode.value = EditorDisplayMode.Editing
}computer.whatthefuck.arcology.app.viewmodel.OrgDocumentEditorViewModel
package computer.whatthefuck.arcology.app.viewmodel
import android.content.Context
import android.util.Log
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.data.CaptureTemplate
import computer.whatthefuck.arcology.app.ui.components.BacklinkItem
import computer.whatthefuck.arcology.app.ui.components.ContentMatchItem
import computer.whatthefuck.arcology.capture.CaptureService
import computer.whatthefuck.arcology.capture.PromptRequest
import computer.whatthefuck.arcology.capture.PublishMetadata
import computer.whatthefuck.arcology.capture.TemplateExpander
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.FlashcardPosition
import computer.whatthefuck.arcology.domain.FlashcardReview
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.PendingAttachment
import computer.whatthefuck.arcology.editor.BreadcrumbEntry
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.HeadingTextUtils
import computer.whatthefuck.arcology.editor.NodeContentParser
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.editor.PlanningInfoUtils
import computer.whatthefuck.arcology.editor.PlanningKind
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import computer.whatthefuck.arcology.app.cache.OrgDocumentCache
import computer.whatthefuck.arcology.flashcard.ClozeService
import computer.whatthefuck.arcology.flashcard.ReviewData
import computer.whatthefuck.arcology.flashcard.SM2Algorithm
import computer.whatthefuck.arcology.utils.parseRoamAliases
import computer.whatthefuck.arcology.utils.parseRoamRefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import xyz.lepisma.orgmode.OrgParseResult
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.parseWithDetails
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.DurationUnit
import kotlin.time.toDuration
private const val TAG = "OrgDocumentEditorViewModel"
private const val SHARE_DEBUG_TAG = "ArcologyShare"
<<vm-types>>
<<vm-class-header>>
<<vm-init>>
<<vm-load-existing-node>>
<<vm-load-file-content>>
<<vm-parse-helpers>>
<<vm-template-expansion>>
<<vm-shared-text>>
<<vm-user-actions>>
<<vm-publish-actions>>
<<vm-flashcard-actions>>
<<vm-attachment-actions>>
<<vm-text-reconstruction>>
<<vm-save-entry>>
<<vm-save-capture>>
<<vm-save-post>>
<<vm-save-edit>>
<<vm-reset-state>>
<<vm-background-loading>>
<<vm-ui-helpers>>
<<vm-refile-node>>
<<vm-id-creation>>
<<vm-refile-heading>>
}Tests
The test file (~3,125 lines) covers template resolution, prompt handling, title/body parsing, content updates, save flow, refile, flashcard type cycling, geolocation, attachments, heading ID creation, FTS backlinks, and parsing state transitions.
computer.whatthefuck.arcology.app.viewmodel.OrgDocumentEditorViewModelTest
package computer.whatthefuck.arcology.app.viewmodel
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.testutils.AppPreferencesTestDouble
import computer.whatthefuck.arcology.app.testutils.RoamRepositoryTestDouble
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.HeadingTextUtils
import computer.whatthefuck.arcology.editor.NodeContentParser
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import computer.whatthefuck.arcology.indexer.FileIndexingService
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.kotest.matchers.string.shouldNotBeBlank
// Type checking utilities
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import android.util.Log
import android.net.Uri
import android.content.Context
import androidx.compose.ui.text.input.TextFieldValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class OrgDocumentEditorViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var appPreferences: AppPreferencesInterface
private lateinit var repository: RoamRepository
private lateinit var quizRepository: QuizRepository
private lateinit var nodeContentParser: NodeContentParser
private lateinit var documentEditorFactory: (computer.whatthefuck.arcology.indexer.FileSystemInterface) -> OrgDocumentEditor
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.e(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0
appPreferences = AppPreferencesTestDouble(
todoStates = listOf("TODO", "DONE", "WAITING"),
captureTemplates = emptyList()
)
// Use test double instead of mock to avoid suspend function issues
repository = RoamRepositoryTestDouble()
quizRepository = mockk(relaxed = true)
nodeContentParser = mockk(relaxed = true)
documentEditorFactory = mockk(relaxed = true)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun newCaptureViewModel(): OrgDocumentEditorViewModel =
OrgDocumentEditorViewModel(
appPreferences = appPreferences,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.NewCapture(null, null)
)
// ============ New Capture Mode Tests ============
@Test
fun `new capture mode sets initial timestamp title`() = runTest {
val viewModel = OrgDocumentEditorViewModel(
appPreferences = appPreferences,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.NewCapture(null, null)
)
advanceUntilIdle()
viewModel.fullContent.value.text.shouldNotBeBlank()
viewModel.fullContent.value.text shouldContain "["
}
@Test
fun `new capture mode sets createId to false initially`() = runTest {
val viewModel = OrgDocumentEditorViewModel(
appPreferences = appPreferences,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.NewCapture(null, null)
)
advanceUntilIdle()
viewModel.createId.value shouldBe false
}
@Test
fun `new capture mode sets editMode to NewCapture`() = runTest {
val viewModel = OrgDocumentEditorViewModel(
appPreferences = appPreferences,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.NewCapture(null, null)
)
advanceUntilIdle()
viewModel.editMode.value shouldBe DocumentEditMode.NewCapture(null, null)
}
@Test
fun `new capture mode starts in editing display mode`() = runTest {
val viewModel = OrgDocumentEditorViewModel(
appPreferences = appPreferences,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.NewCapture(null, null)
)
advanceUntilIdle()
viewModel.displayMode.value shouldBe EditorDisplayMode.Editing
}
// ============ Template Tests ============
@Test
fun `selectTemplate applies template todo state`() = runTest {
val templateId = "template1"
val template = computer.whatthefuck.arcology.app.data.CaptureTemplate(
id = templateId,
name = "Test Template",
titlePattern = "",
bodyPattern = "",
tags = emptyList(),
todoState = "TODO",
createId = false
)
// Use AppPreferencesTestDouble which supports getCaptureTemplate
val prefs = AppPreferencesTestDouble(
todoStates = listOf("TODO", "DONE", "WAITING"),
captureTemplates = listOf(template)
)
val viewModel = OrgDocumentEditorViewModel(
appPreferences = prefs,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.NewCapture(null, null)
)
advanceUntilIdle()
viewModel.selectTemplate(templateId)
advanceUntilIdle()
// Template expansion happens asynchronously: the TODO state should be set
// and the content should include the TODO prefix
viewModel.todoState.value shouldBe "TODO"
}
@Test
fun `clearTemplate resets content to timestamp`() = runTest {
val viewModel = OrgDocumentEditorViewModel(
appPreferences = appPreferences,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.NewCapture(null, null)
)
advanceUntilIdle()
viewModel.clearTemplate()
advanceUntilIdle()
viewModel.fullContent.value.text.shouldNotBeBlank()
viewModel.selectedTemplateId.value shouldBe null
viewModel.pendingPrompts.value shouldBe emptyList()
}
// ============ Publish Metadata Tests ============
@Test
fun `publish disabled by default`() = runTest {
val viewModel = newCaptureViewModel()
advanceUntilIdle()
viewModel.publishEnabled.value shouldBe false
viewModel.arcologyKey.value shouldBe ""
viewModel.arcologyExpire.value shouldBe null
viewModel.arcologyAllowCrawl.value shouldBe null
viewModel.arcologyPageTemplate.value shouldBe null
}
@Test
fun `enabling publish sets createId`() = runTest {
val viewModel = newCaptureViewModel()
advanceUntilIdle()
viewModel.updatePublishEnabled(true)
advanceUntilIdle()
viewModel.publishEnabled.value shouldBe true
viewModel.createId.value shouldBe true
}
@Test
fun `arcology key populates properties drawer`() = runTest {
val viewModel = newCaptureViewModel()
advanceUntilIdle()
viewModel.updatePublishEnabled(true)
viewModel.updateArcologyKey("garden/my-page")
advanceUntilIdle()
viewModel.fullContent.value.text shouldContain ":ARCOLOGY_KEY: garden/my-page"
}
@Test
fun `publish metadata fields populate properties drawer`() = runTest {
val viewModel = newCaptureViewModel()
advanceUntilIdle()
viewModel.updatePublishEnabled(true)
viewModel.updateArcologyKey("garden/my-page")
viewModel.updateArcologyExpire("[2026-09-07 Mon 14:00]")
viewModel.updateArcologyAllowCrawl(true)
viewModel.updateArcologyPageTemplate("wide")
advanceUntilIdle()
val text = viewModel.fullContent.value.text
text shouldContain ":ARCOLOGY_KEY: garden/my-page"
text shouldContain ":ARCOLOGY_EXPIRE: [2026-09-07 Mon 14:00]"
text shouldContain ":ARCOLOGY_ALLOW_CRAWL: t"
text shouldContain ":ARCOLOGY_PAGE_TEMPLATE: wide"
}
@Test
fun `allow crawl false emits nil`() = runTest {
val viewModel = newCaptureViewModel()
advanceUntilIdle()
viewModel.updatePublishEnabled(true)
viewModel.updateArcologyKey("garden/private")
viewModel.updateArcologyAllowCrawl(false)
advanceUntilIdle()
viewModel.fullContent.value.text shouldContain ":ARCOLOGY_ALLOW_CRAWL: nil"
}
@Test
fun `publish toggle off removes only arcology key`() = runTest {
val viewModel = newCaptureViewModel()
advanceUntilIdle()
viewModel.updatePublishEnabled(true)
viewModel.updateArcologyKey("garden/my-page")
viewModel.updateArcologyExpire("[2026-09-07 Mon 14:00]")
viewModel.updateArcologyPageTemplate("topic")
advanceUntilIdle()
viewModel.updatePublishEnabled(false)
advanceUntilIdle()
val text = viewModel.fullContent.value.text
text shouldNotContain ":ARCOLOGY_KEY:"
// expire/crawl/template survive as ViewModel state for re-publishing,
// but the drawer drops all ARCOLOGY properties while unpublishable
text shouldNotContain ":ARCOLOGY_PAGE_TEMPLATE:"
viewModel.arcologyExpire.value shouldBe "[2026-09-07 Mon 14:00]"
viewModel.arcologyPageTemplate.value shouldBe "topic"
}
@Test
fun `publish chip works on level0 nodes`() = runTest {
val viewModel = OrgDocumentEditorViewModel(
appPreferences = appPreferences,
repository = repository,
quizRepository = quizRepository,
nodeContentParser = nodeContentParser,
documentEditorFactory = documentEditorFactory,
mode = DocumentEditMode.EditExisting("level0-node")
)
advanceUntilIdle()
viewModel.updatePublishEnabled(true)
viewModel.updateArcologyKey("garden/top-level")
advanceUntilIdle()
viewModel.fullContent.value.text shouldContain ":ARCOLOGY_KEY: garden/top-level"
}
@Test
fun `publish toggle off clears key state`() = runTest {
val viewModel = newCaptureViewModel()
advanceUntilIdle()
viewModel.updatePublishEnabled(true)
viewModel.updateArcologyKey("garden/my-page")
advanceUntilIdle()
viewModel.updatePublishEnabled(false)
advanceUntilIdle()
viewModel.arcologyKey.value shouldBe ""
viewModel.fullContent.value.text shouldNotContain ":ARCOLOGY_KEY:"
}
}Future Work
File Attachment Handling
Render
file:*.IMAGE_FILE_EXTENSIONlinks with images inline, resized to device windowRender
attachment:FILE_NAMElinks inline using node ID → attach dir mappingAttach files to Capture node via share intent
Support multiple attach dir options as upstream org-mode does
Related Modules
Uses
OrgDocumentEditorfrom editor.orgUses
OrgDocumentCachefrom app/data.orgUses
NodeContentParserfrom editor.orgUses
CaptureService,TemplateExpanderfrom capture-core.orgUses
RoamRepositoryfrom models.orgConsumed by screen.org
Consumed by bootstrap.org (MainActivity - inline capture routing)