Arcology Engine

Org Document Editor ViewModel

Contents

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 subdirectory

  • EditExisting — editing an existing node by ID

And two EditorDisplayMode values:

  • ReadOnly — AST-rendered browsing with breadcrumbs, backlinks

  • Editing — 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 as TextFieldValue

  • parseState — lazy AST parse result (Idle Loading Ready)

  • editModeDocumentEditMode (NewCapture or EditExisting)

  • displayModeEditorDisplayMode (ReadOnly or Editing)

  • todoState, tags, aliases, refs — metadata extracted from heading/properties

  • createId — whether to generate an :ID: property on save

  • geoCoords — optional GeoCoordinate for location capture

  • publishEnabled, arcologyKey, arcologyExpire, arcologyAllowCrawl, arcologyPageTemplate — Arcology publishing metadata (see Publish)

  • flashcardType, clozeType — flashcard configuration

  • attachments — list of PendingAttachment queued for save

  • captureTemplates, selectedTemplateId, pendingPrompts — template expansion state

  • node — the current OrgNode being edited (for EditExisting mode)

  • parentNodes — breadcrumb hierarchy for read-only view

  • backlinks, contentMatches — FTS results for backlinks panel

  • existingAttachments — attachments already saved to the node

  • flashcardPositions, reviewHistory — flashcard review data for read-only panel

  • backlinksExpanded — collapsible panel state

  • editorStateEditorState (Loading Editing Saving Saved Error)

  • errorMessage — error message for display

Template Expansion

For NewCapture mode, templates are expanded via TemplateExpander:

  • %<...> timestamps are replaced with current time

  • %^{prompt} placeholders trigger PromptDialog via _pendingPrompts

  • %c clipboard placeholders are noted but not implemented (no clipboard access)

  • %l location placeholders use current geoCoords

After %^{...} prompts are resolved, TemplateExpander.applyPromptResponses() substitutes them into the text.

Save Flow

Capture save (saveCapture)

  • Resolve or create daily file via AndroidFileSystem / CaptureService

  • Build entry content from current state (headingLine, properties, body)

  • Append heading via editor.appendHeading()

  • Copy attachments via handleAttachments() if entry has an ID

  • Create REVIEW_DATA drawer via createReviewDataForFlashcard() if flashcard type is set

Edit save (saveExistingNode)

  • Extract body content (after properties drawer)

  • Call editor.replaceHeadingBody() to update the file

  • Reset 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 by onHeadingRefile in ReadOnlyView)

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 via editor.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).

kotlin#+name: vm-types
/**
 ,* 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

kotlin#+name: vm-class-header
/**
 ,* 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?> = _arcologyPageTemplate

Initialization

kotlin#+name: vm-init
    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

kotlin#+name: vm-load-existing-node
    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

kotlin#+name: vm-load-file-content
    /**
     ,* 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

kotlin#+name: vm-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

kotlin#+name: vm-background-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

kotlin#+name: vm-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

kotlin#+name: vm-shared-text
    /**
     ,* 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.

kotlin#+name: vm-user-actions
    /**
     ,* 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).

kotlin#+name: vm-publish-actions
    /**
     ,* 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

kotlin#+name: vm-flashcard-actions
    /**
     ,* 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

kotlin#+name: vm-attachment-actions
    /**
     ,* 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

kotlin#+name: vm-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

kotlin#+name: vm-save-entry
    /**
     ,* 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

kotlin#+name: vm-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

kotlin#+name: vm-save-edit
    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

kotlin#+name: vm-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

kotlin#+name: vm-save-post
    /**
     ,* 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

kotlin#+name: vm-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

kotlin#+name: vm-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

kotlin#+name: vm-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

kotlin#+name: vm-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

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/OrgDocumentEditorViewModel.kt:noweb yes
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

kotlin:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/OrgDocumentEditorViewModelTest.kt
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_EXTENSION links with images inline, resized to device window

  • Render attachment:FILE_NAME links inline using node ID → attach dir mapping

  • Attach files to Capture node via share intent

  • Support multiple attach dir options as upstream org-mode does

Related Modules