Status: phase 2 (import) IMPLEMENTED 2026-10-10 — GpxImportService, share-intent wiring, ImportReviewScreen v1, CLI gpx-import land below. The export half (phase 3) is still outline. Design settled in maps/index.org; the shared-layer models live in maps/models.org.
Import first is dependency order, not sequence preference: the importer builds the models, merge semantics and deterministic IDs that the exporter consumes.
GpxImportService
Takes a URI and owns everything downstream of parsing, so all three entry points are thin: share intent (wired in parseSharedIntent), an "import gpx attachment" action on any node (attachment resolution already exists via AttachmentResolver), and the jvm CLI (same commonMain service, mirrors how the exporter is planned for app UI + cli).
Import flow
parse (GpxParser) → diff against existing nodes by ID policy → write ONE org file per imported gpx (placement convention below) via ensureFile + writeFile → FlowFileIndexer.indexFile() on it. The map screen picks up the new GEO_COORDS nodes with no further wiring.
Placement: one gpx → one org file, groups ↔ outline path (DECIDED 2026-10-09)
One import writes a single org file in the imports directory: <imports>/<file-name>.org, named for the gpx <metadata><name> (favorites) or the entry point's own name fallback. This supersedes the earlier file-per-OsmAnd-group convention (2026-10-08, now retired) — groups are not files; the OsmAnd group axis maps onto the org heading outline path around each point heading, which keeps round trip symmetric with the exporter's outline path → groups mapping (phase 3):
group
Siuslaw/campsites→ = Siuslaw= → = campsites= → =* point=. The split is on/in both directions: group names carry slashes exactly where outline paths do, so a re-export joins the point's outline back into the original group name; - the empty default group (the""bucket) → point headings directly at file top level, so re-export emits no<type>and the point lands back in the default bucket; - a group literally namedpersonal→ a = personal= heading — each of OsmAnd's two personal-bucket spellings round-trips to itself;group appearance rides the file-level
ARCOLOGY_POINTS_GROUPSproperty — one gpx = one file makes that property trivially file-scoped (previously it needed per-file subset filtering);point headings keep the phase-1 property mapping (
ARCOLOGY_GROUPraw value plus the slugified tag), so map-screen tag filtering and the exporter's property rehydration are unchanged;*placement policy (DECIDED 2026-10-10, on-device finding): only files under the imports directory are the import system's own home — those points refile to follow their group (in-file outline moves, and cross-file consolidation when a group lands elsewhere). A point whose org home is anywhere else (journal entries, user captures — the ~93 foreign GEO_COORDS headings that ride an OsmAnd export) updates in place*: OsmAnd-owned fields refresh, outline and file are never touched. This is what keeps a re-import of the phone's OsmAnd export from yanking the whole org tree into
gpx_imports/;v1 does not prune group headings emptied by moves; they linger as empty structure. Fine to iterate on later.
Merge policy
Heading-scoped upsert, never global:
OsmAnd-owned fields — refresh on every import: lat/lon, ele, time, group/type, icon/color/background, address, hidden. (Group changes follow the point to its file only when that file is an import home; foreign homes refresh the property + tag in place.)
Org-owned fields — never clobber: org-native tags beyond the group tag,
:ID:, child headings, body edits (except thearcology_orgrestore rule).Body policy — first contact:
osmand:arcology_orgextension present → body verbatim from the extension (org is truth, desc is presentation). Elsedesc→ plain-text-ified body (native favorites carry HTML desc; the strip pass ishtmlToPlainTextin the service file). Else minimal body (coords + capture time). On refresh: the org body is org-owned anddescchanges never rewrite it — decs only seed first-contact bodies — except thearcology_orgrestore rule: UPDATE/MOVED entries carrying the extension re-assert the carried org text verbatim. v1 nuance: UNCHANGED entries skip refresh entirely, so an org-side body edit on an otherwise-untouched arcology point survives — the newer org text wins over the stale extension, which matches "org is truth" even when the trigger differs.Removals — point deleted in OsmAnd but still in arcology: surfaced in the review screen as suggested deletions, never auto-executed.
Metadata extensions on first contact
GpxImportService reads <metadata><extensions> (osmand:arcology_key / osmand:arcology_feed) when present to seed publishing file-properties on first contact; falls back to the group-name→file-name placement convention when absent (the OsmAnd round-trip case — OsmAnd's own favorites save does NOT preserve metadata extensions, while wpt-level ones survive forever).
Review screen consequence
It is a diff surface, not an import gate: "2 new, 5 unchanged, 1 moved, 1 orphaned — delete?"
GpxImportService implementation (phase 2, 2026-10-10)
The service lives in commonMain beside the phase-1 models (gis package) and takes the three entry points' shared shape: a path string (disk path for the CLI, content:// or tree-relative for Android — FileSystemInterface.readFile handles both), plus ensureFile for create-if-absent writes (added to the contract for exactly this; see roam/indexer.org). Diffing runs against the repository; the on-disk target file doubles as a stale-index guard (an id already in the file is refreshed, never re-inserted). Apply composes PropertyDrawerUtils /= HeadingTextUtils edits into one read-modify-write per touched file plus one reindex each — no per-property editor round trips, no direct DB writes (the reindex is the DB update).
Diff models
/** What apply will do with one imported waypoint. */
enum class GpxImportAction { NEW, UPDATE, UNCHANGED, MOVED }
/**
* One waypoint's proposed change against the org side. plan() computes it,
* apply() executes it; the review screen renders summary counts.
*/
data class GpxImportEntry(
val wpt: GpxWaypoint,
/** Raw OsmAnd group ("" = default bucket); drives outline, tag and property. */
val group: String,
val nodeId: String,
val action: GpxImportAction,
/** Target outline path ABOVE the point heading (group segments; empty = file top level). */
val outlinePath: List<String>,
/** OsmAnd-owned properties whose stored value differs — written by apply. */
val changedProperties: Map<String, String> = emptyMap(),
/** OsmAnd-owned properties the stored node has but the gpx dropped — removed by apply. */
val removedPropertyKeys: List<String> = emptyList(),
/**
* True when the node was missing from the repository but the target file
* already carried its :ID: (crash between write and reindex). Apply forces
* a reindex even when the refreshed properties are identical, so a stale
* index actually gets rebuilt.
*/
val staleRefresh: Boolean = false,
/**
* The node's current org file per the repository (null for NEW). apply()
* routes in-place refreshes through it — a point living outside the
* imports directory keeps its org home.
*/
val currentFile: String? = null
) {
/** Field names for diff display. */
val changedFields: List<String>
get() = changedProperties.keys.toList() + removedPropertyKeys
}
/**
* An org node in the target file the gpx no longer covers — a point deleted
* in OsmAnd that still exists here. Suggested deletion only; never
* auto-executed (apply deletes solely from confirmedOrphans).
*/
data class GpxOrphan(
val nodeId: String,
val title: String?,
/** Outline path above the orphan heading. */
val outlinePath: List<String>
)
data class GpxImportDiff(
val sourceName: String,
val targetFile: String,
/** Where the gpx was read from (disk path or content URI); apply snapshots it. */
val sourcePath: String?,
val gpx: Gpx,
val entries: List<GpxImportEntry>,
val orphans: List<GpxOrphan>,
val warnings: List<String>
) {
val newCount: Int get() = entries.count { it.action == GpxImportAction.NEW }
val updateCount: Int get() = entries.count { it.action == GpxImportAction.UPDATE }
val movedCount: Int get() = entries.count { it.action == GpxImportAction.MOVED }
val unchangedCount: Int get() = entries.count { it.action == GpxImportAction.UNCHANGED }
}
data class GpxImportResult(
val file: String,
val created: Boolean,
val imported: Int,
val unchanged: Int,
val orphansDeleted: Int,
val warnings: List<String> = emptyList()
)HTML desc → plain text
/**
* Minimal HTML-to-plain-text pass for OsmAnd favorites descs, which arrive as
* HTML fragments. Entity order matters: named entities decode BEFORE &
* so text legitimately spelling "<p>" survives as its literal
* characters instead of collapsing into a live tag pair.
*/
fun htmlToPlainText(html: String): String {
if ('<' !in html && '&' !in html) return html
var text = html.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
text = text.replace(Regex("</(p|div|li|tr|h[1-6])>", RegexOption.IGNORE_CASE), "\n")
text = text.replace(Regex("<[^>]*>"), "")
text = text.replace(Regex(" ", RegexOption.IGNORE_CASE), " ")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("&", "&")
return text.split('\n')
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n")
}The service
class GpxImportService(
private val repository: RoamRepository,
private val fileSystem: FileSystemInterface,
private val indexingService: FileIndexingService,
/** Tree-relative directory the import file is placed in, e.g. "imports". */
private val importsDirectory: String
) {
companion object {
/** File-level property pointing at the verbatim source gpx snapshot. */
const val PROP_SOURCE_GPX = "ARCOLOGY_GPX_SOURCE"
/**
* OsmAnd-owned heading properties: refreshed on every apply from the
* gpx; removed when the gpx drops them. Body/text and the title are
* org-owned; the only body write besides first-contact seeding is the
* arcology_org restore rule.
*/
val OWNED_PROPERTY_KEYS = listOf(
GeoCoordinate.PROPERTY_KEY,
GpxOrgMapping.PROP_ARCOLOGY_GROUP,
GpxOrgMapping.PROP_OSMAND_TIME,
GpxOrgMapping.PROP_OSMAND_CMT,
GpxOrgMapping.PROP_OSMAND_ICON,
GpxOrgMapping.PROP_OSMAND_COLOR,
GpxOrgMapping.PROP_OSMAND_BACKGROUND,
GpxOrgMapping.PROP_OSMAND_EXTENSIONS
)
const val UNTITLED = "unnamed point"
private val FILE_ID_SCAN = Regex(":ID:\\s+(.*\\S)\\s*$", RegexOption.MULTILINE)
}
private val parser = GpxParser()
private class BufferState(var content: String, var dirty: Boolean)
// ---- parse & placement ----
fun parse(content: String): GpxParseResult = parser.parse(content)
/** True for a favorites file: parsed gpx with at least one waypoint. */
fun isImportable(result: GpxParseResult): Boolean =
result.gpx?.waypoints?.isNotEmpty() == true
/** Read and parse an import source through this service's filesystem (content URI or disk path). */
suspend fun readGpx(readPath: String): GpxParseResult =
parser.parse(fileSystem.readFile(readPath))
fun fileNameFor(gpx: Gpx, fallback: String): String {
val metadataName = gpx.metadata?.name?.trim().orEmpty()
val raw = metadataName
.ifBlank { fallback }
.removeSuffix(".gpx")
.removeSuffix(".org")
.replace('/', '-')
.replace('\\', '-')
.trim()
return "${raw.ifBlank { "favorites" }}.org"
}
/** Group name → outline path above the point heading; "" → file top level. */
fun outlinePathFor(group: String): List<String> =
group.split('/').map { it.trim() }.filter { it.isNotEmpty() }
// ---- plan ----
/**
* Diff the parsed gpx against the repository (plus the on-disk target
* file as a stale-index guard). Returns null when the content isn't an
* importable favorites file. Never mutates anything.
*/
suspend fun plan(
sourceName: String,
result: GpxParseResult,
sourcePath: String? = null
): GpxImportDiff? {
val gpx = result.gpx ?: return null
if (gpx.waypoints.isEmpty()) return null
val targetFile = "$importsDirectory/${fileNameFor(gpx, sourceName)}"
val warnings = result.warnings.toMutableList()
// Disk is the source of truth: when the target file is gone, DB rows
// for it are stale (the user's reindex may not have purged them yet)
// and every import must rebuild from scratch — never let repository
// state alone mark entries UNCHANGED, or apply() happily writes a
// header-only file over the wreckage.
val fileExists = fileSystem.fileExists(targetFile)
val fileIds = if (fileExists) {
FILE_ID_SCAN.findAll(fileSystem.readFile(targetFile))
.mapTo(mutableSetOf()) { it.groupValues[1] }
} else {
emptySet()
}
val dbNodesForFile = repository.getNodesByFile(targetFile)
if (!fileExists && dbNodesForFile.isNotEmpty()) {
warnings.add(
"repository still has ${dbNodesForFile.size} nodes for $targetFile " +
"but the file is missing — treating the import as fresh"
)
}
val existingFileNodes = if (fileExists) dbNodesForFile else emptyList()
val entries = mutableListOf<GpxImportEntry>()
for (wpt in gpx.waypoints) {
val group = GpxOrgMapping.groupFor(wpt)
val outline = outlinePathFor(group)
val nodeId = GpxOrgMapping.orgIdFor(wpt)
val incoming = GpxOrgMapping.toOrgProperties(wpt)
val node = repository.getNodeById(nodeId)
when {
node == null && nodeId in fileIds -> {
// Stale-index guard: the file already carries this heading
// (e.g. crash between write and reindex) — refresh instead
// of re-inserting a duplicate.
entries.add(
GpxImportEntry(
wpt, group, nodeId, GpxImportAction.UPDATE, outline,
changedProperties = incoming,
removedPropertyKeys = emptyList(),
staleRefresh = true
)
)
}
node == null -> entries.add(
GpxImportEntry(wpt, group, nodeId, GpxImportAction.NEW, outline)
)
else -> {
val current = node.properties
val changed = OWNED_PROPERTY_KEYS
.mapNotNull { key ->
val inc = incoming[key] ?: return@mapNotNull null
if (inc != current[key]) key to inc else null
}
.toMap()
val removed = OWNED_PROPERTY_KEYS
.filter { incoming[it] == null && current.containsKey(it) }
// The database stores outline paths as /-joined strings and
// re-splits them on /, so a point TITLE containing a slash
// inflates the segment count. Compare against the fully
// split expected shape (outline + current title) instead of
// slicing off the last segment.
val title = node.title.orEmpty()
val expectedPath = if (outline.isEmpty()) {
title.split('/')
} else {
(outline.joinToString("/") + "/" + title).split('/')
}
// Placement policy (DECIDED 2026-10-10, on-device finding):
// files under importsDirectory are the import system's own
// home and refile to follow their group; a point whose org
// home is anywhere else (journal entries, captures) is
// org-owned — update in place, outline and file untouched.
val inImportHome = node.file.startsWith("$importsDirectory/")
val outlineMatches = node.outlinePath == expectedPath
val action = when {
// A stored node whose heading is absent from the target
// file on disk (deleted file, or a file rebuilt from a
// bare header) imports fresh — the repository cannot
// vouch for disk state that no longer exists.
node.file == targetFile && nodeId !in fileIds -> GpxImportAction.NEW
inImportHome && !outlineMatches -> GpxImportAction.MOVED
changed.isNotEmpty() || removed.isNotEmpty() -> GpxImportAction.UPDATE
else -> GpxImportAction.UNCHANGED
}
entries.add(
GpxImportEntry(
wpt, group, nodeId, action, outline,
changedProperties = changed,
removedPropertyKeys = removed,
currentFile = node.file
)
)
}
}
}
// Orphans: id-bearing nodes in the target file the gpx no longer
// covers. Children of an orphaned point ride that deletion, so any
// candidate whose outline path extends an already-listed orphan's is
// covered; group headings never surface (they carry no :ID:).
val incomingIds = entries.mapTo(mutableSetOf()) { it.nodeId }
val orphans = mutableListOf<GpxOrphan>()
val orphanPaths = mutableListOf<List<String>>()
for (node in existingFileNodes.sortedWith(compareBy({ it.level }, { it.position }))) {
if (node.id in incomingIds || node.level == 0) continue
val outline = node.outlinePath
if (orphanPaths.any { path -> outline.size > path.size && outline.subList(0, path.size) == path }) continue
orphans.add(GpxOrphan(node.id, node.title, outline.dropLast(1)))
orphanPaths.add(outline)
}
return GpxImportDiff(
sourceName = displayTitle(gpx, sourceName),
targetFile = targetFile,
sourcePath = sourcePath,
gpx = gpx,
entries = entries,
orphans = orphans,
warnings = warnings
)
}
private fun displayTitle(gpx: Gpx, fallback: String): String {
val metadataName = gpx.metadata?.name?.trim().orEmpty()
return metadataName.ifBlank { fallback.trim().ifBlank { "favorites" } }
}
// ---- apply ----
/**
* Execute a diff: one read-modify-write per touched file, one reindex per
* written file. Orphans delete only when their id is in confirmedOrphans.
*/
suspend fun apply(diff: GpxImportDiff, confirmedOrphans: Set<String> = emptySet()): GpxImportResult {
val warnings = diff.warnings.toMutableList()
val created = !fileSystem.fileExists(diff.targetFile)
val buffers = LinkedHashMap<String, BufferState>()
val target = if (created) {
BufferState(buildFileHeader(diff), dirty = true)
} else {
BufferState(fileSystem.readFile(diff.targetFile), dirty = false)
}
buffers[diff.targetFile] = target
// 1) confirmed orphans: re-located by id, subtree removed. Every
// id-bearing node inside the removed subtree is recorded so the
// stale rows can be dropped after reindex (the indexer replaces
// node rows but never deletes vanished ones).
var orphansDeleted = 0
val removedNodeIds = mutableListOf<String>()
for (orphan in diff.orphans.filter { it.nodeId in confirmedOrphans }) {
val pos = findPositionById(target.content, orphan.nodeId)
if (pos == null) {
warnings.add("orphan ${orphan.nodeId}: heading not found in ${diff.targetFile}")
continue
}
val level = headingLevelAt(target.content, pos)
val nextPos = HeadingTextUtils.findNextHeadingPosition(target.content, pos, level)
?: target.content.length
removedNodeIds.addAll(
FILE_ID_SCAN.findAll(target.content.substring(pos, nextPos)).map { it.groupValues[1] }
)
target.content = HeadingTextUtils.removeFullHeading(target.content, pos, level)
target.dirty = true
orphansDeleted++
}
// 2) MOVED: extract each heading from its source buffer, prune there,
// insert under the target outline; the field refresh follows in 3.
for (entry in diff.entries.filter { it.action == GpxImportAction.MOVED }) {
val sourceFile = repository.getNodeById(entry.nodeId)?.file
if (sourceFile == null) {
warnings.add("moved point ${entry.nodeId}: no stored node — imported fresh")
insertFreshPoint(target, entry)
continue
}
if (sourceFile != diff.targetFile && !fileSystem.fileExists(sourceFile)) {
warnings.add("moved point ${entry.nodeId}: source file $sourceFile missing — imported fresh")
insertFreshPoint(target, entry)
continue
}
val source = if (sourceFile == diff.targetFile) {
target
} else {
buffers.getOrPut(sourceFile) { BufferState(fileSystem.readFile(sourceFile), dirty = false) }
}
val pos = findPositionById(source.content, entry.nodeId)
if (pos == null) {
warnings.add("moved point ${entry.nodeId}: heading not found in $sourceFile — imported fresh")
if (sourceFile != diff.targetFile) buffers.remove(sourceFile)
insertFreshPoint(target, entry)
continue
}
val level = headingLevelAt(source.content, pos)
val headingContent = HeadingTextUtils.extractFullHeading(source.content, pos, level)
source.content = HeadingTextUtils.removeFullHeading(source.content, pos, level)
source.dirty = true
val targetLevel = entry.outlinePath.size + 1
val adjusted = HeadingTextUtils.adjustHeadingLevels(headingContent, targetLevel)
val (content, parentPos, parentLevel) = ensureOutline(target.content, entry.outlinePath)
target.content = HeadingTextUtils.insertHeadingUnder(content, parentPos, parentLevel, adjusted)
target.dirty = true
}
// 3) refresh UPDATE + MOVED entries: group tag, OsmAnd-owned
// properties, arcology_org body restore. UPDATE edits the entry's
// own home-file buffer (in-place for foreign org homes); MOVED
// entries were already relocated into the target in step 2, so
// they refresh there — the repository still says the old file and
// must not be consulted.
for (entry in diff.entries) {
when (entry.action) {
GpxImportAction.UPDATE -> {
if (entry.staleRefresh) target.dirty = true
val home = entry.currentFile ?: diff.targetFile
val state = if (home == diff.targetFile) {
target
} else {
try {
buffers.getOrPut(home) {
BufferState(fileSystem.readFile(home), dirty = false)
}
} catch (e: Exception) {
warnings.add("refresh ${entry.nodeId}: could not read $home: ${e.message}")
continue
}
}
val refreshed = refreshEntry(state.content, entry)
if (refreshed != state.content) {
state.content = refreshed
state.dirty = true
}
}
GpxImportAction.MOVED -> {
val refreshed = refreshEntry(target.content, entry)
if (refreshed != target.content) {
target.content = refreshed
target.dirty = true
}
}
else -> {}
}
}
// 4) NEW entries.
for (entry in diff.entries.filter { it.action == GpxImportAction.NEW }) {
insertFreshPoint(target, entry)
}
// 5) import-owned file properties: points_groups + source snapshot.
applyFileProperties(target, diff, warnings)
// 6) write + reindex, per touched file. Source files go first, the
// target last: for a moved node the old file's reindex drops its
// FTS row and the target's re-inserts it.
for ((file, state) in buffers) {
if (!state.dirty || file == diff.targetFile) continue
writeAndIndex(file, state, warnings)
}
if (target.dirty) {
writeAndIndex(diff.targetFile, target, warnings)
}
// 7) explicit row cleanup for confirmed orphan removals: the indexer
// replaces node rows but never deletes ones that vanished from the
// file, and the FTS pre-pass only covers still-indexed files.
for (nodeId in removedNodeIds) {
try {
repository.deleteNode(nodeId)
repository.deleteNodeFromFts(nodeId)
} catch (e: Exception) {
warnings.add("db cleanup failed for $nodeId: ${e.message}")
}
}
return GpxImportResult(
file = diff.targetFile,
created = created,
imported = diff.newCount + diff.updateCount + diff.movedCount,
unchanged = diff.unchangedCount,
orphansDeleted = orphansDeleted,
warnings = warnings
)
}
/** Write one buffer back and reindex it; failures warn, never throw. */
private suspend fun writeAndIndex(
file: String,
state: BufferState,
warnings: MutableList<String>
) {
try {
if (fileSystem.fileExists(file)) {
fileSystem.writeFile(file, state.content)
} else {
fileSystem.ensureFile(file, state.content)
}
} catch (e: Exception) {
warnings.add("failed writing $file: ${e.message}")
return
}
try {
indexingService.indexFile(file)
} catch (e: Exception) {
warnings.add("reindex failed for $file: ${e.message}")
}
}
/**
* Refresh one existing heading in place: rewrite the group tag, write
* changed OsmAnd-owned properties, drop removed ones, and re-assert the
* org body from arcology_org when the extension is present. Every write
* re-locates the heading — drawer edits shift offsets.
*/
private fun refreshEntry(content: String, entry: GpxImportEntry): String {
var result = content
var pos = findPositionById(result, entry.nodeId) ?: return result
val newTag = GpxOrgMapping.orgTagFor(entry.group)
val oldTag = GpxOrgMapping.orgTagFor(
drawerProp(result, pos, GpxOrgMapping.PROP_ARCOLOGY_GROUP)
)
if (oldTag != newTag) {
rewriteHeadingTag(result, pos, oldTag, newTag)?.let { result = it }
}
entry.changedProperties.forEach { (key, value) ->
pos = findPositionById(result, entry.nodeId) ?: return result
PropertyDrawerUtils.setProperty(result, pos, key, value)?.let { result = it }
}
for (key in entry.removedPropertyKeys) {
pos = findPositionById(result, entry.nodeId) ?: return result
PropertyDrawerUtils.removeProperty(result, pos, key)?.let { result = it }
}
val orgBody = entry.wpt.extensions[EXT_ARCOLOGY_ORG]
if (orgBody != null) {
pos = findPositionById(result, entry.nodeId) ?: return result
val level = headingLevelAt(result, pos)
val nextPos = HeadingTextUtils.findNextHeadingPosition(result, pos, level) ?: result.length
result = HeadingTextUtils.replaceHeadingBody(result, pos, nextPos, orgBody)
}
return result
}
/** Insert a freshly composed point heading under its outline target. */
private fun insertFreshPoint(target: BufferState, entry: GpxImportEntry) {
val (content, parentPos, parentLevel) = ensureOutline(target.content, entry.outlinePath)
val pointLevel = entry.outlinePath.size + 1
val adjusted = HeadingTextUtils.adjustHeadingLevels(buildPointHeading(entry), pointLevel)
target.content = HeadingTextUtils.insertHeadingUnder(content, parentPos, parentLevel, adjusted)
target.dirty = true
}
/**
* Walk (and create when missing) the group-heading chain for an outline
* path; returns (content, parentPosition, parentLevel) to insert under.
* The empty path returns the file scope — level-0 inserts append at EOF.
*/
private fun ensureOutline(content: String, outlinePath: List<String>): Triple<String, Int, Int> {
var current = content
var pos = 0
var level = 0
for (segment in outlinePath) {
val childLevel = level + 1
val found = findGroupHeading(current, pos, childLevel, segment)
if (found == null) {
current = HeadingTextUtils.insertHeadingUnder(
current, pos, level, "${"*".repeat(childLevel)} $segment\n"
)
pos = findGroupHeading(current, pos, childLevel, segment) ?: pos
} else {
pos = found
}
level = childLevel
}
return Triple(current, pos, level)
}
/** First heading line at [level] whose title is [title], at/after [from]. */
private fun findGroupHeading(content: String, from: Int, level: Int, title: String): Int? {
val pattern = Regex(
"^\\*{$level} ${Regex.escape(title)}(\\s+:[\\w@#%:]+:)?\\s*$",
RegexOption.MULTILINE
)
val match = pattern.find(content.substring(from)) ?: return null
return from + match.range.first
}
/** Compose a complete point heading (line, drawer, body), ending in a newline. */
private fun buildPointHeading(entry: GpxImportEntry): String {
val wpt = entry.wpt
val title = wpt.name?.trim()?.takeIf { it.isNotEmpty() } ?: UNTITLED
val sb = StringBuilder()
sb.append("* ").append(title)
GpxOrgMapping.orgTagFor(entry.group)?.let { sb.append(" :").append(it).append(':') }
sb.append('\n')
sb.appendLine(":PROPERTIES:")
sb.appendLine(":ID: ${entry.nodeId}")
GpxOrgMapping.toOrgProperties(wpt).forEach { (key, value) -> sb.appendLine(":$key: $value") }
sb.appendLine(":END:")
val coord = GeoCoordinate(wpt.latitude, wpt.longitude, wpt.ele)
val body = wpt.extensions[EXT_ARCOLOGY_ORG]
?: GpxOrgMapping.bodyTextFor(wpt.copy(desc = wpt.desc?.let { htmlToPlainText(it) }))
?: GpxOrgMapping.minimalBody(coord)
for (line in body.split('\n')) sb.appendLine(line)
return sb.toString()
}Remaining private helpers (header authoring, file-level property editing, id/heading/tag line scans) and the assembled tangle target:
/** Author the header of a fresh import target: drawer, file properties, title. */
private fun buildFileHeader(diff: GpxImportDiff): String {
val sb = StringBuilder()
sb.appendLine(":PROPERTIES:")
sb.appendLine(":ID: ${generateFileId()}")
GpxOrgMapping.filePropertiesFor(diff.gpx).forEach { (key, value) -> sb.appendLine(":$key: $value") }
if (diff.sourcePath != null) {
sb.appendLine(":$PROP_SOURCE_GPX: ${snapshotFileFor(diff)}")
}
diff.gpx.metadata?.extensions?.let { ext ->
ext["arcology_key"]?.let { sb.appendLine(":ARCOLOGY_KEY: $it") }
ext["arcology_feed"]?.let { sb.appendLine(":ARCOLOGY_FEED: $it") }
}
sb.appendLine(":END:")
sb.appendLine("#+title: ${diff.sourceName}")
sb.appendLine()
return sb.toString()
}
/** File id for a fresh import target: capture-style timestamp id. */
@OptIn(ExperimentalTime::class)
private fun generateFileId(): String {
val now = Clock.System.now()
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
return CaptureService.generateId(local, now.nanosecondsOfSecond / 1000)
}
/** Verbatim snapshot path for the imported gpx: <imports>/sources/<name>.gpx. */
private fun snapshotFileFor(diff: GpxImportDiff): String {
val base = diff.targetFile.substringAfterLast('/').removeSuffix(".org")
return "$importsDirectory/sources/$base.gpx"
}
/** Refresh the import-owned file-level properties on the target buffer. */
private suspend fun applyFileProperties(
target: BufferState,
diff: GpxImportDiff,
warnings: MutableList<String>
) {
val groupsValue = GpxOrgMapping.filePropertiesFor(diff.gpx)[GpxOrgMapping.PROP_ARCOLOGY_POINTS_GROUPS]
val currentGroups = getFileLevelProperty(target.content, GpxOrgMapping.PROP_ARCOLOGY_POINTS_GROUPS)
if (currentGroups != groupsValue) {
target.content = if (groupsValue == null) {
removeFileLevelProperty(target.content, GpxOrgMapping.PROP_ARCOLOGY_POINTS_GROUPS)
} else {
setFileLevelProperty(target.content, GpxOrgMapping.PROP_ARCOLOGY_POINTS_GROUPS, groupsValue)
}
target.dirty = true
}
val sourcePath = diff.sourcePath ?: return
val snapshotPath = snapshotFileFor(diff)
try {
if (fileSystem.fileExists(snapshotPath)) {
fileSystem.writeFile(snapshotPath, fileSystem.readFile(sourcePath))
} else {
fileSystem.ensureFile(snapshotPath, fileSystem.readFile(sourcePath))
}
val currentSource = getFileLevelProperty(target.content, PROP_SOURCE_GPX)
if (currentSource != snapshotPath) {
target.content = setFileLevelProperty(target.content, PROP_SOURCE_GPX, snapshotPath)
target.dirty = true
}
} catch (e: Exception) {
warnings.add("source gpx snapshot failed: ${e.message}")
}
}
// ---- file-level property editing (import files keep their drawer at the top) ----
private fun getFileLevelProperty(content: String, key: String): String? {
if (!content.trimStart().startsWith(":PROPERTIES:")) return null
val endIdx = content.indexOf(":END:")
if (endIdx == -1) return null
return Regex("^:${Regex.escape(key)}:[ \\t]*(.*)$", RegexOption.MULTILINE)
.find(content.substring(0, endIdx))
?.groupValues
?.get(1)
?.trim()
?.takeIf { it.isNotEmpty() }
}
private fun setFileLevelProperty(content: String, key: String, value: String): String {
val line = ":$key: $value"
if (!content.trimStart().startsWith(":PROPERTIES:")) {
return ":PROPERTIES:\n$line\n:END:\n${content.trimStart()}"
}
val endIdx = content.indexOf(":END:")
if (endIdx == -1) return content
val drawer = content.substring(0, endIdx)
val pattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
return if (pattern.containsMatchIn(drawer)) {
content.replace(pattern) { match ->
if (match.range.first < endIdx) line else match.value
}
} else {
drawer + line + "\n" + content.substring(endIdx)
}
}
private fun removeFileLevelProperty(content: String, key: String): String {
val endIdx = content.indexOf(":END:")
if (endIdx == -1) return content
val pattern = Regex("^:${Regex.escape(key)}:.*\\n?", RegexOption.MULTILINE)
if (!pattern.containsMatchIn(content.substring(0, endIdx))) return content
return content.replace(pattern) { match ->
if (match.range.first < endIdx) "" else match.value
}
}
// ---- content scans ----
/** Heading start position carrying this :ID: value; delegates to the shared mapper. */
private fun findPositionById(content: String, nodeId: String): Int? =
GpxOrgMapping.findHeadingPositionById(content, nodeId)
private fun headingLevelAt(content: String, headingPosition: Int): Int =
GpxOrgMapping.headingLevelAt(content, headingPosition)
private fun drawerProp(content: String, headingPosition: Int, key: String): String? {
val level = headingLevelAt(content, headingPosition)
val nextPos = HeadingTextUtils.findNextHeadingPosition(content, headingPosition, level) ?: content.length
val section = content.substring(headingPosition, nextPos)
return Regex("^:${Regex.escape(key)}:[ \\t]*(.*)$", RegexOption.MULTILINE)
.find(section)
?.groupValues
?.get(1)
?.trim()
?.takeIf { it.isNotEmpty() }
}
/**
* Rewrite the heading line's tag span: drop [oldTag], ensure [newTag].
* Tags beyond the group tag are org-owned and never disturbed.
*/
private fun rewriteHeadingTag(
content: String,
headingPosition: Int,
oldTag: String?,
newTag: String?
): String? {
if (oldTag == newTag) return null
val lineEnd = content.indexOf('\n', headingPosition).let { if (it == -1) content.length else it }
val line = content.substring(headingPosition, lineEnd)
val starMatch = Regex("^(\\*+)\\s+").find(line) ?: return null
val stars = starMatch.groupValues[1]
val rest = line.substring(starMatch.range.last + 1)
val tags = LinkedHashSet<String>()
var title = rest
GpxOrgMapping.TAG_SPAN.find(rest)?.let { span ->
title = rest.substring(0, span.range.first)
span.groupValues[1].split(':')
.map { it.trim() }
.filter { it.isNotEmpty() }
.forEach { tags.add(it) }
}
oldTag?.let { tags.remove(it) }
newTag?.let { tags.add(it) }
val newLine = buildString {
append(stars)
append(' ')
append(title.trimEnd())
if (tags.isNotEmpty()) {
append(' ')
for (tag in tags) append(':').append(tag)
append(':')
}
}
return content.substring(0, headingPosition) + newLine + content.substring(lineEnd)
}GpxImportService.kt — assembled tangle target
package computer.whatthefuck.arcology.gis
import computer.whatthefuck.arcology.capture.CaptureService
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.editor.HeadingTextUtils
import computer.whatthefuck.arcology.editor.PropertyDrawerUtils
import computer.whatthefuck.arcology.indexer.FileIndexingService
import computer.whatthefuck.arcology.indexer.FileSystemInterface
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
<<imp-diff-models>>
<<imp-html-plain-text>>
<<imp-service-class>>
<<imp-service-helpers>>
}GpxImportServiceTest
Real pipeline per test: in-memory DB + TestFileSystem + a real FlowFileIndexer (the roam/indexer-test.org conventions), so apply's write+reindex round trips through the actual indexer and orphan/refresh assertions run against the indexed DB.
package computer.whatthefuck.arcology.gis
import computer.whatthefuck.arcology.database.DatabaseTestUtils
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.FlowFileIndexer
import computer.whatthefuck.arcology.indexer.TestFileSystem
import computer.whatthefuck.arcology.parser.OrgFileParser
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
private const val IMPORTS_DIR = "gpx_imports"
/** Fresh service per test: in-memory repository, in-memory filesystem, real indexer. */
private suspend fun <T> withService(
block: suspend (
service: GpxImportService,
fileSystem: TestFileSystem,
repository: RoamRepository,
indexer: FlowFileIndexer
) -> T
): T {
val repository = DatabaseTestUtils.createTestRepository()
val fileSystem = TestFileSystem()
val indexer = FlowFileIndexer(repository, OrgFileParser(), fileSystem)
val service = GpxImportService(repository, fileSystem, indexer, IMPORTS_DIR)
try {
return block(service, fileSystem, repository, indexer)
} finally {
DatabaseTestUtils.clearDatabase(repository)
}
}
private const val TARGET_PATH = "$IMPORTS_DIR/favorites.org"
private fun resourceText(name: String): String =
GpxImportServiceTest::class.java.getResourceAsStream(name)
?.use { it.readBytes().decodeToString() }
?: error("test resource $name missing from the test classpath")
private fun favoritesGpx(
vararg wpts: String,
metadata: String = "<metadata><name>favorites</name></metadata>",
groups: String? = null
): String =
"<gpx version=\"1.1\" creator=\"t\" xmlns:osmand=\"$OSMAND_GPX_NAMESPACE\">" +
metadata +
(groups?.let { "<extensions><osmand:points_groups>$it</osmand:points_groups></extensions>" } ?: "") +
wpts.joinToString("") +
"</gpx>"
private fun groupXml(name: String, extra: String = ""): String = "<osmand:group name=\"$name\"$extra/>"
private fun wptXml(
name: String,
lat: Double = 44.0,
lon: Double = -123.0,
ele: Double? = null,
type: String? = null,
time: String? = null,
desc: String? = null,
links: List<Pair<String, String>> = emptyList(),
extensions: Map<String, String> = emptyMap()
): String = buildString {
append("<wpt lat=\"").append(lat).append("\" lon=\"").append(lon).append("\">")
append("<name>").append(name).append("</name>")
ele?.let { append("<ele>").append(it).append("</ele>") }
time?.let { append("<time>").append(it).append("</time>") }
desc?.let { append("<desc>").append(it).append("</desc>") }
type?.let { append("<type>").append(it).append("</type>") }
links.forEach { (href, text) -> append("<link href=\"$href\"><text>$text</text></link>") }
if (extensions.isNotEmpty()) {
append("<extensions>")
extensions.forEach { (key, value) ->
append("<osmand:").append(key).append('>').append(value).append("</osmand:").append(key).append('>')
}
append("</extensions>")
}
append("</wpt>")
}
class GpxImportServiceTest : DescribeSpec({
describe("fresh import") {
it("builds one file; groups become the outline; ids and properties land") {
withService { service, fs, repo, _ ->
val gpx = favoritesGpx(
wptXml("clearing leading in to old growth"),
wptXml("house point", type = "personal"),
wptXml(
"Siuslaw Falls", lat = 44.1422919, lon = -122.9749531, ele = 442.0,
type = "Siuslaw/campsites", time = "2024-12-30T00:13:16Z"
),
wptXml(
"Coburg Ridge", type = "Coburg Hills and Cascades",
extensions = mapOf(
"icon" to "park",
"color" to "#EECC22",
"amenity_origin" to "blm"
)
),
groups = listOf(
groupXml("personal"),
groupXml("", " pinned=\"true\""),
groupXml("Siuslaw/campsites")
).joinToString("")
)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.targetFile shouldBe TARGET_PATH
diff.newCount shouldBe 4
diff.warnings shouldBe emptyList()
val result = service.apply(diff)
result.imported shouldBe 4
result.created shouldBe true
result.warnings shouldBe emptyList()
val content = fs.readFile(TARGET_PATH)
content shouldContain "* clearing leading in to old growth\n"
content shouldContain "* personal\n"
content shouldContain "** house point"
content shouldContain "* Siuslaw\n"
content shouldContain "** campsites\n"
content shouldContain "*** Siuslaw Falls"
content shouldContain ":ARCOLOGY_GROUP: Siuslaw/campsites"
content shouldContain ":GEO_COORDS: 44.1422919,-122.9749531,442.0"
content shouldContain ":GEO_COORDS: 44.0,-123.0\n"
content shouldContain ":OSMAND_TIME: 2024-12-30T00:13:16Z"
content shouldContain ":OSMAND_ICON: park"
content shouldContain ":OSMAND_EXTENSIONS: amenity_origin=blm"
content shouldContain "* Coburg Ridge :Coburg_Hills_and_Cascades:"
content shouldContain
":ARCOLOGY_POINTS_GROUPS: name=personal|name=;pinned=true|name=Siuslaw/campsites"
val siuslawId = WaypointId.forWaypoint("Siuslaw/campsites", "Siuslaw Falls")
val node = repo.getNodeById(siuslawId).shouldNotBeNull()
node.level shouldBe 3
node.file shouldBe TARGET_PATH
// The parser's outlinePath carries the heading line's tag span;
// the meaningful comparison is the chain above the point.
node.outlinePath.dropLast(1) shouldBe listOf("Siuslaw", "campsites")
repo.getNodeById(WaypointId.forWaypoint("", "clearing leading in to old growth"))
.shouldNotBeNull()
}
}
it("body policy: arcology_org verbatim, html desc to text plus link, minimal fallback") {
withService { service, fs, _, _ ->
val gpx = favoritesGpx(
wptXml("kept", type = "g", extensions = mapOf(EXT_ARCOLOGY_ORG to "authentic org body")),
wptXml(
"fancy", type = "g",
desc = "<p>near & far</p><br/>line two",
links = listOf("https://example.com/p.jpg" to "photo")
),
wptXml("bare", type = "g")
)
service.apply(service.plan("favorites", service.parse(gpx)).shouldNotBeNull())
val content = fs.readFile(TARGET_PATH)
content shouldContain "authentic org body"
content shouldContain "near & far"
content shouldContain "line two"
content shouldContain "[[https://example.com/p.jpg][photo]]"
content shouldContain "Coordinates: 44.0,-123.0"
content shouldNotContain "<p>"
}
}
it("metadata extensions seed publishing file properties") {
withService { service, fs, _, _ ->
val gpx = favoritesGpx(
wptXml("p", type = "g"),
metadata = "<metadata><name>favorites</name><extensions>" +
"<osmand:arcology_key>arcology/maps</osmand:arcology_key>" +
"<osmand:arcology_feed>arcology/maps-feed</osmand:arcology_feed>" +
"</extensions></metadata>"
)
service.apply(service.plan("favorites", service.parse(gpx)).shouldNotBeNull())
val content = fs.readFile(TARGET_PATH)
content shouldContain ":ARCOLOGY_KEY: arcology/maps"
content shouldContain ":ARCOLOGY_FEED: arcology/maps-feed"
}
}
}
describe("re-import") {
it("is idempotent: second plan is all UNCHANGED, file stays byte-identical") {
withService { service, fs, _, _ ->
val gpx = favoritesGpx(
wptXml("A", type = "g", ele = 442.0, time = "2024-12-30T00:13:16Z"),
wptXml("B", type = "g")
)
service.apply(service.plan("favorites", service.parse(gpx)).shouldNotBeNull())
val before = fs.readFile(TARGET_PATH)
val second = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
second.unchangedCount shouldBe 2
second.newCount shouldBe 0
second.orphans shouldBe emptyList()
service.apply(second)
fs.readFile(TARGET_PATH) shouldBe before
}
}
it("refreshes drifted osmand-owned fields; org body edits survive") {
withService { service, fs, _, _ ->
val gpx1 = favoritesGpx(
wptXml("Point", type = "Coburg Hills and Cascades", time = "2024-12-30T00:13:16Z")
)
service.apply(service.plan("favorites", service.parse(gpx1)).shouldNotBeNull())
fs.writeFile(TARGET_PATH, fs.readFile(TARGET_PATH).replace("Coordinates: 44.0,-123.0", "user note"))
val gpx2 = favoritesGpx(
wptXml(
"Point", type = "Coburg Hills and Cascades",
time = "2024-12-30T00:13:16Z",
extensions = mapOf("icon" to "cafe")
)
)
val diff2 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
diff2.updateCount shouldBe 1
diff2.entries.first().changedFields shouldBe listOf("OSMAND_ICON")
service.apply(diff2)
val content = fs.readFile(TARGET_PATH)
content shouldContain ":OSMAND_ICON: cafe"
content shouldContain "user note"
content shouldNotContain "Coordinates: 44.0,-123.0"
}
}
it("drops osmand-owned fields the gpx no longer carries") {
withService { service, fs, _, _ ->
val gpx1 = favoritesGpx(
wptXml(
"Point", type = "Coburg Hills and Cascades", time = "2024-12-30T00:13:16Z",
extensions = mapOf("icon" to "cafe")
)
)
service.apply(service.plan("favorites", service.parse(gpx1)).shouldNotBeNull())
val gpx2 = favoritesGpx(wptXml("Point", type = "Coburg Hills and Cascades"))
val diff2 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
diff2.entries.first().removedPropertyKeys shouldBe listOf("OSMAND_TIME", "OSMAND_ICON")
service.apply(diff2)
val content = fs.readFile(TARGET_PATH)
content shouldNotContain ":OSMAND_TIME:"
content shouldNotContain ":OSMAND_ICON:"
}
}
it("arcology_org restore reasserts the carried text on refresh") {
withService { service, fs, _, _ ->
val gpx1 = favoritesGpx(
wptXml(
"Point", type = "g",
extensions = mapOf(EXT_ARCOLOGY_ORG to "carried body", "icon" to "cafe")
)
)
service.apply(service.plan("favorites", service.parse(gpx1)).shouldNotBeNull())
fs.writeFile(
TARGET_PATH,
fs.readFile(TARGET_PATH).replace("carried body", "user edit")
)
val gpx2 = favoritesGpx(
wptXml(
"Point", type = "g",
extensions = mapOf(EXT_ARCOLOGY_ORG to "carried body", "icon" to "shop")
)
)
val diff2 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
diff2.updateCount shouldBe 1
service.apply(diff2)
val content = fs.readFile(TARGET_PATH)
content shouldContain "carried body"
content shouldNotContain "user edit"
content shouldContain ":OSMAND_ICON: shop"
}
}
it("desc changes alone stay UNCHANGED") {
withService { service, _, _, _ ->
val gpx1 = favoritesGpx(wptXml("Point", type = "g", desc = "v1"))
service.apply(service.plan("favorites", service.parse(gpx1)).shouldNotBeNull())
val gpx2 = favoritesGpx(wptXml("Point", type = "g", desc = "v2"))
val diff2 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
diff2.unchangedCount shouldBe 1
diff2.updateCount shouldBe 0
}
}
it("wpt names with slashes converge instead of churning MOVED") {
withService { service, fs, _, _ ->
// The DB re-splits olp on '/', so a slash in the TITLE inflates
// the segment count; the expected-shape comparison absorbs it.
val gpx = favoritesGpx(
wptXml("tables in meadow/pond loop", type = "Coburg Hills and Cascades")
)
service.apply(service.plan("favorites", service.parse(gpx)).shouldNotBeNull())
val before = fs.readFile(TARGET_PATH)
val second = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
second.unchangedCount shouldBe 1
second.movedCount shouldBe 0
service.apply(second)
fs.readFile(TARGET_PATH) shouldBe before
}
}
}
describe("moved") {
it("refiles in-file when the group changes and arcology_id rides along") {
withService { service, fs, repo, _ ->
val stableId = "00000000-aaaa-bbbb-cccc-000000000001"
val gpx1 = favoritesGpx(
wptXml("Point", type = "personal", extensions = mapOf(EXT_ARCOLOGY_ID to stableId))
)
service.apply(service.plan("favorites", service.parse(gpx1)).shouldNotBeNull())
val gpx2 = favoritesGpx(
wptXml("Point", type = "Siuslaw/campsites", extensions = mapOf(EXT_ARCOLOGY_ID to stableId))
)
val diff2 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
diff2.movedCount shouldBe 1
service.apply(diff2)
val content = fs.readFile(TARGET_PATH)
content shouldContain "** campsites\n"
content shouldContain "*** Point :Siuslaw_campsites:"
content shouldContain ":ARCOLOGY_GROUP: Siuslaw/campsites"
val node = repo.getNodeById(stableId).shouldNotBeNull()
node.level shouldBe 3
node.outlinePath.dropLast(1) shouldBe listOf("Siuslaw", "campsites")
}
}
it("updates a foreign-file point in place, never refiled (org owns its home)") {
withService { service, fs, repo, indexer ->
val stableId = "00000000-aaaa-bbbb-cccc-000000000002"
val awayFile = "notes/away-file.org"
fs.addFile(
awayFile,
":PROPERTIES:\n:ID: away-file-node\n:END:\n#+title: away\n\n" +
"* 2026-10-10\n** Point :personal:\n:PROPERTIES:\n:ID: $stableId\n" +
":GEO_COORDS: 44.0,-123.0\n:END:\nCoordinates: 44.0,-123.0\n"
)
indexer.indexFile(awayFile)
// the on-device case: an OsmAnd re-export of a point that
// lived in a journal, now moved to a different group —
// osmand-owned fields refresh, the file and outline don't move
val gpx = favoritesGpx(
wptXml(
"Point", type = "Coburg Hills and Cascades",
extensions = mapOf(EXT_ARCOLOGY_ID to stableId)
)
)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.updateCount shouldBe 1
diff.movedCount shouldBe 0
service.apply(diff)
val node = repo.getNodeById(stableId).shouldNotBeNull()
node.file shouldBe awayFile // still home
val content = fs.readFile(awayFile)
content shouldContain ":ARCOLOGY_GROUP: Coburg Hills and Cascades"
content shouldContain ":Coburg_Hills_and_Cascades:" // tag refreshed on the line
content shouldContain "* 2026-10-10\n** Point" // outline untouched
fs.readFile(TARGET_PATH) shouldNotContain stableId
}
}
it("keeps an import-file point put when its outline already matches") {
withService { service, fs, repo, indexer ->
val stableId = "00000000-aaaa-bbbb-cccc-000000000003"
val otherImport = "gpx_imports/other.org"
fs.addFile(
otherImport,
":PROPERTIES:\n:ID: other-file-node\n:END:\n#+title: other\n\n" +
"* g\n** A :g:\n:PROPERTIES:\n:ID: $stableId\n" +
":GEO_COORDS: 44.0,-123.0\n:ARCOLOGY_GROUP: g\n:END:\nbody\n"
)
indexer.indexFile(otherImport)
val gpx = favoritesGpx(
wptXml("A", type = "g", extensions = mapOf(EXT_ARCOLOGY_ID to stableId))
)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.unchangedCount shouldBe 1 // right shape in an import home: stay put
diff.movedCount shouldBe 0
}
}
it("refiles an import-file point into the current import only when its outline moved") {
withService { service, fs, repo, indexer ->
val stableId = "00000000-aaaa-bbbb-cccc-000000000004"
val otherImport = "gpx_imports/other.org"
fs.addFile(
otherImport,
":PROPERTIES:\n:ID: other-file-node\n:END:\n#+title: other\n\n" +
"* g\n** A :g:\n:PROPERTIES:\n:ID: $stableId\n" +
":GEO_COORDS: 44.0,-123.0\n:ARCOLOGY_GROUP: g\n:END:\nbody\n"
)
indexer.indexFile(otherImport)
val gpx = favoritesGpx(
wptXml("A", type = "Coburg Hills and Cascades", extensions = mapOf(EXT_ARCOLOGY_ID to stableId))
)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.movedCount shouldBe 1
service.apply(diff)
val node = repo.getNodeById(stableId).shouldNotBeNull()
node.file shouldBe TARGET_PATH
fs.readFile(otherImport) shouldNotContain stableId
fs.readFile(TARGET_PATH) shouldContain "** A :Coburg_Hills_and_Cascades:"
}
}
}
describe("orphans") {
it("surfaces absent points; deletion requires confirmation") {
withService { service, fs, repo, _ ->
val gpx1 = favoritesGpx(
wptXml("A", type = "personal"),
wptXml("B", type = "personal")
)
service.apply(service.plan("favorites", service.parse(gpx1)).shouldNotBeNull())
val bId = WaypointId.forWaypoint("personal", "B")
val gpx2 = favoritesGpx(wptXml("A", type = "personal"))
val diff2 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
diff2.orphans.map { it.nodeId } shouldBe listOf(bId)
// without confirmation: nothing deleted
service.apply(diff2)
fs.readFile(TARGET_PATH) shouldContain "B"
repo.getNodeById(bId).shouldNotBeNull()
// with confirmation: subtree removed and de-indexed
val diff3 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
val deleted = service.apply(diff3, confirmedOrphans = setOf(bId))
deleted.orphansDeleted shouldBe 1
val afterDeletion = fs.readFile(TARGET_PATH)
afterDeletion shouldNotContain bId
repo.getNodeById(bId).shouldBeNull()
}
}
it("children of an orphaned point do not surface separately") {
withService { service, fs, repo, indexer ->
val gpx1 = favoritesGpx(wptXml("A", type = "personal"))
service.apply(service.plan("favorites", service.parse(gpx1)).shouldNotBeNull())
val aId = WaypointId.forWaypoint("personal", "A")
val kidBlock =
"*** kid\n:PROPERTIES:\n:ID: kid-id\n:END:\nkid body\n"
fs.writeFile(TARGET_PATH, fs.readFile(TARGET_PATH).trimEnd('\n') + "\n" + kidBlock)
indexer.indexFile(TARGET_PATH)
val gpx2 = favoritesGpx(wptXml("Z", type = "personal"))
val diff2 = service.plan("favorites", service.parse(gpx2)).shouldNotBeNull()
diff2.orphans.map { it.nodeId } shouldBe listOf(aId)
service.apply(diff2, confirmedOrphans = setOf(aId))
val content = fs.readFile(TARGET_PATH)
content shouldNotContain "** A"
content shouldNotContain "kid-id"
repo.getNodeById(aId).shouldBeNull()
repo.getNodeById("kid-id").shouldBeNull()
}
}
}
describe("guards") {
it("plan surfaces malformed-wpt warnings and imports the rest") {
withService { service, _, _, _ ->
val gpx = favoritesGpx(
"<wpt><name>no coords</name></wpt>",
wptXml("fine", type = "g")
)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.newCount shouldBe 1
diff.warnings shouldBe listOf("wpt #0: missing or invalid lat/lon — skipped")
}
}
it("plan returns null for non-gpx and track-only content") {
withService { service, _, _, _ ->
service.plan("x", service.parse("<html><body>nope</body></html>")).shouldBeNull()
service.plan(
"x",
service.parse(
"<gpx xmlns=\"$GPX_NAMESPACE\"><trk><trkseg><trkpt lat=\"44.0\" lon=\"-123.0\"/></trkseg></trk></gpx>"
)
).shouldBeNull()
}
}
it("stale-index guard: ids already in the file are refreshed, not duplicated") {
withService { service, fs, repo, _ ->
val gpx = favoritesGpx(wptXml("A", type = "g"), wptXml("B", type = "g"))
service.apply(service.plan("favorites", service.parse(gpx)).shouldNotBeNull())
// Simulate a crash between write and reindex: file on disk, empty DB.
DatabaseTestUtils.clearDatabase(repo)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.updateCount shouldBe 2
diff.newCount shouldBe 0
service.apply(diff)
val content = fs.readFile(TARGET_PATH)
content.split(":ID:").size - 1 shouldBe 3 // file node + A + B, no duplicates
repo.getNodeById(WaypointId.forWaypoint("g", "A")).shouldNotBeNull()
repo.getNodeById(WaypointId.forWaypoint("g", "B")).shouldNotBeNull()
}
}
it("deleted file with stale DB rows re-imports fresh, never truncates") {
withService { service, fs, repo, _ ->
val gpx = favoritesGpx(wptXml("A", type = "g"), wptXml("B", type = "g"))
service.apply(service.plan("favorites", service.parse(gpx)).shouldNotBeNull())
// the user's delete with a reindex that never purged the rows:
// disk truth (file gone) must beat repository memory
fs.removeFile(TARGET_PATH)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.newCount shouldBe 2
diff.updateCount shouldBe 0
diff.unchangedCount shouldBe 0
diff.orphans shouldBe emptyList() // stale rows are never deletion suggestions
diff.warnings.first() shouldContain "but the file is missing"
service.apply(diff)
val content = fs.readFile(TARGET_PATH)
content shouldContain "** A :g:"
content shouldContain "** B :g:"
repo.getNodeById(WaypointId.forWaypoint("g", "A")).shouldNotBeNull()
repo.getNodeById(WaypointId.forWaypoint("g", "B")).shouldNotBeNull()
}
}
it("truncated file (bare header) re-inserts its missing headings") {
withService { service, fs, repo, _ ->
val gpx = favoritesGpx(wptXml("A", type = "g"))
service.apply(service.plan("favorites", service.parse(gpx)).shouldNotBeNull())
// the post-crash state: a file holding only its header
val bareHeader = fs.readFile(TARGET_PATH).substringBefore("* ")
fs.writeFile(TARGET_PATH, bareHeader)
val diff = service.plan("favorites", service.parse(gpx)).shouldNotBeNull()
diff.newCount shouldBe 1
diff.unchangedCount shouldBe 0
service.apply(diff)
fs.readFile(TARGET_PATH) shouldContain "** A :g:"
repo.getNodeById(WaypointId.forWaypoint("g", "A")).shouldNotBeNull()
}
}
}
describe("on-device experiment (real 203/210 fixtures)") {
it("absorbs the osmand re-export without yanking import-home points") {
withService { service, fs, repo, _ ->
// step 1: the arcology2go export the phone shipped to OsmAnd
val first = service.plan("first", service.parse(resourceText("/gpx/arcology-203-export.gpx"))).shouldNotBeNull()
first.newCount shouldBe 203
service.apply(first)
// step 2: OsmAnd's re-export after the user moved/added points
val gpx = service.parse(resourceText("/gpx/osmand-210-reexport.gpx")).gpx.shouldNotBeNull()
gpx.waypoints.size shouldBe 210
gpx.waypoints.count { it.extensions.containsKey(EXT_ARCOLOGY_ID) } shouldBe 203
val second = service.plan("favorites", service.parse(resourceText("/gpx/osmand-210-reexport.gpx"))).shouldNotBeNull()
second.newCount shouldBe 7 // the user's OsmAnd additions
second.orphans shouldBe emptyList() // nothing was deleted in OsmAnd
// every arcology_id point resolves; moved+unchanged partitions it
second.newCount + second.updateCount + second.movedCount + second.unchangedCount shouldBe 210
// observed split when pinned (2026-10-10): new=7, update=147
// (OsmAnd regenerates <time> for touched points — osmand-owned
// refresh), moved=0 (group distribution identical), unchanged=56
service.apply(second)
// absorption touched the import files, never the org tree:
// every id-bearing node still lives under gpx_imports/
repo.getNodesWithLocation().filter { (node, _) ->
node.properties.containsKey("ARCOLOGY_GROUP") ||
node.properties.containsKey(GpxOrgMapping.PROP_OSMAND_EXTENSIONS)
}.forEach { (node, _) ->
if (node.level > 0) {
node.file.takeIf { it.startsWith("gpx_imports/") }
?: error("moved ${node.id} out of the import home: ${node.file}")
}
}
}
}
}
describe("htmlToPlainText") {
it("strips tags and decodes entities in the right order") {
htmlToPlainText("<p>near & far</p><br/>two") shouldBe "near & far\ntwo"
htmlToPlainText("<code> &lt; &quot;") shouldBe "<code> < ""
}
it("passes plain text through untouched") {
htmlToPlainText("already plain") shouldBe "already plain"
}
}
describe("file naming") {
it("metadata name wins and is sanitized") {
withService { service, _, _, _ ->
service.fileNameFor(
Gpx(metadata = GpxMetadata(name = "favorites")),
"other.gpx"
) shouldBe "favorites.org"
service.fileNameFor(
Gpx(metadata = GpxMetadata(name = "a/b\\c.gpx")),
"x"
) shouldBe "a-b-c.org"
}
}
it("falls back to the entry-point name and to favorites") {
withService { service, _, _, _ ->
service.fileNameFor(Gpx(), "Coburg Hills.gpx") shouldBe "Coburg Hills.org"
service.fileNameFor(Gpx(metadata = GpxMetadata(name = " ")), " ") shouldBe "favorites.org"
}
}
}
})CLI entry point: gpx-import
The third thin entry point. Reads the gpx itself, plans, prints the diff, applies (or --dry-run). Orphans are listed but the CLI never auto-deletes them (the design's never-auto-executed rule; the review screen is where confirming happens). Imports land under --org-dir with the same ensureFile placement the app uses; tangles as src/jvmMain/kotlin/computer/whatthefuck/arcology/gis/GpxImportCommand.kt and registers in =ArcologyCli='s subcommand list (app/cli.org).
package computer.whatthefuck.arcology.gis
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.types.path
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.database.RoamRepositoryImpl
import computer.whatthefuck.arcology.indexer.IndexingConfig
import computer.whatthefuck.arcology.indexer.JvmFileSystem
import computer.whatthefuck.arcology.indexer.createIndexingService
import java.io.File
import kotlin.io.path.Path
import kotlin.io.path.pathString
import kotlinx.coroutines.runBlocking
/**
* CLI entry point for [[https://osmand.net][OsmAnd]] favorites import:
*
* arcology gpx-import /path/to/favorites.gpx --org-dir ~/org --db ~/org/arcology.db
* arcology gpx-import favorites.gpx --dry-run
*
* Plans the import, prints the diff, applies unless --dry-run. Orphans are
* listed but never deleted — deletion is the review screen's confirmable
* action (phase 2d) or an org-side edit.
*/
class GpxImportCommand : CliktCommand(
name = "gpx-import",
help = "Import an OsmAnd favorites GPX file into the org directory"
) {
private val gpxFile by argument(help = "Path to the favorites .gpx")
.path(mustExist = true, canBeDir = false)
private val orgDir by option("--org-dir", help = "Org directory root the imports land in").default(".")
private val dbPath by option("--db", help = "Database path").default("arcology.db")
private val importsDir by option("--imports-dir", help = "Imports directory, relative to the org root").default("gpx_imports")
private val dryRun by option("--dry-run", help = "Print the diff, write nothing").flag(default = false)
override fun run() = runBlocking {
val expandedFile = expandTilde(gpxFile.pathString)
val expandedOrg = expandTilde(orgDir)
val databasePath = if (dbPath == "arcology.db") {
File(expandedOrg, "arcology.db").absolutePath
} else {
dbPath
}
val content = try {
File(expandedFile).readText()
} catch (e: Exception) {
echo("failed to read $expandedFile: ${e.message}", err = true)
return@runBlocking
}
val fileSystem = JvmFileSystem(Path(expandedOrg))
val repository = RoamRepositoryImpl(DatabaseFactory.createDatabase(databasePath))
val config = IndexingConfig(orgDirectoryPath = expandedOrg, databasePath = databasePath)
val indexingService = createIndexingService(databasePath, config, expandedOrg)
val service = GpxImportService(repository, fileSystem, indexingService, importsDir)
val parsed = service.parse(content)
if (!service.isImportable(parsed)) {
echo(
"not an importable OsmAnd favorites file (waypoints: ${parsed.gpx?.waypoints?.size ?: 0})",
err = true
)
return@runBlocking
}
val diff = service.plan(
expandedFile.substringAfterLast('/'),
parsed,
// absolute so JvmFileSystem's org-rooted resolve passes it through
sourcePath = File(expandedFile).absolutePath
) ?: return@runBlocking
echo("target: ${diff.targetFile}")
echo(
"new=${diff.newCount} update=${diff.updateCount} " +
"moved=${diff.movedCount} unchanged=${diff.unchangedCount}"
)
diff.warnings.forEach { echo("warning: $it", err = true) }
if (diff.orphans.isNotEmpty()) {
echo("orphans (the CLI never auto-deletes)")
diff.orphans.forEach { orphan ->
echo(" ${orphan.title ?: orphan.nodeId} (${orphan.outlinePath.joinToString("/")})")
}
}
if (dryRun) {
echo("dry run — nothing written")
return@runBlocking
}
val result = service.apply(diff)
echo(
"imported=${result.imported} unchanged=${result.unchanged} " +
"orphansDeleted=${result.orphansDeleted}"
)
result.warnings.forEach { echo("warning: $it", err = true) }
}
private fun expandTilde(path: String): String = when {
path == "~" -> System.getProperty("user.home")
path.startsWith("~/") -> System.getProperty("user.home") + path.substring(1)
else -> path
}
}GpxExportService (phase 3, 2026-10-10)
The org→OsmAnd half, commonMain beside the import service. DECIDED 2026-10-10 (user calls superseding settled-design lines): *`<desc>` = raw org markup (not an OrgHtmlRenderer rendering — the earlier "desc is presentation" framing is retired: the desc IS org truth verbatim, and the `osmand:arcology_org` extension stays as the canonical restore channel), and no `<link>` emission* — the published-attachment URL channel needs the JVM-only `published_attachments` backfill plus the deferred web surface; links round-trip through the body. Groups derive per point: `ARCOLOGY_GROUP` property, else the parent outline path joined with `/` (DB `olp` re-splits on `/`, so the title's own slash count is dropped first), else no `<type>` (OsmAnd default bucket). The `<name>` is the stored title with its tag span stripped — the parser keeps tags in the title, and OsmAnd identity is group+name.
Emission is a *hand-rolled writer over the phase-1 model* (`GpxXmlWriter`): xmlutil's writer overloads are JVM-only extensions invisible from commonMain, and the `@Transient` extension surfaces with `osmand:` prefixed children are the same expressiveness gap that forced the phase-1 hand-rolled reader. Determinism comes from construction: no timestamps, fixed attribute order, wpts sorted by group→name, groups sorted by name (the `""` bucket sorts first), flat unindented output — byte-identical double export is the gate.
Diff result + writer
/** Service output: the gpx text plus non-fatal notes (skipped keys, unreadable files, missing ids). */
data class GpxExport(val content: String, val warnings: List<String>)/**
* Deterministic GPX 1.1 writer over the phase-1 [Gpx] model — the inverse of
* [GpxParser]: the file this emits parses back to the same model. Hand-rolled
* for the same reason the reader is: the extension surfaces (arbitrary
* osmand:-prefixed children, the structural osmand:points_groups block) are
* inexpressible as =@Serializable= transforms, and xmlutil's writer factory
* overloads live in its JVM-only extensions, invisible from commonMain.
*
* Default namespace is GPX 1.1 (plain element names), the osmand prefix is
* declared on the root. Output is flat — no indentation, no pretty printing.
*/
object GpxXmlWriter {
fun write(gpx: Gpx): String {
val emitter = XmlEmitter()
emitter.open(
"gpx",
"version" to gpx.version,
"creator" to gpx.creator,
"xmlns" to GPX_NAMESPACE,
"xmlns:osmand" to OSMAND_GPX_NAMESPACE
)
gpx.metadata?.let { metadata ->
emitter.open("metadata")
metadata.name?.let { emitter.leaf("name", it) }
metadata.description?.let { emitter.leaf("desc", it) }
metadata.links.forEach { link -> writeLink(emitter, link) }
if (metadata.extensions.isNotEmpty()) {
emitter.open("extensions")
metadata.extensions.forEach { (key, value) -> emitter.leaf("osmand:$key", value) }
emitter.close()
}
emitter.close()
}
gpx.waypoints.forEach { writeWaypoint(emitter, it) }
if (gpx.pointGroups.isNotEmpty() || gpx.rootExtensions.isNotEmpty()) {
emitter.open("extensions")
if (gpx.pointGroups.isNotEmpty()) {
emitter.open("osmand:points_groups")
gpx.pointGroups.forEach { group -> writePointGroup(emitter, group) }
emitter.close()
}
gpx.rootExtensions.forEach { (key, value) -> emitter.leaf("osmand:$key", value) }
emitter.close()
}
emitter.close()
return emitter.toString()
}
private fun writeWaypoint(emitter: XmlEmitter, wpt: GpxWaypoint) {
emitter.open(
"wpt",
"lat" to wpt.latitude.toString(),
"lon" to wpt.longitude.toString()
)
wpt.ele?.let { emitter.leaf("ele", it.toString()) }
wpt.time?.let { emitter.leaf("time", it) }
wpt.name?.let { emitter.leaf("name", it) }
wpt.cmt?.let { emitter.leaf("cmt", it) }
wpt.desc?.let { emitter.leaf("desc", it) }
wpt.links.forEach { link -> writeLink(emitter, link) }
wpt.type?.let { emitter.leaf("type", it) }
if (wpt.extensions.isNotEmpty()) {
emitter.open("extensions")
wpt.extensions.forEach { (key, value) -> emitter.leaf("osmand:$key", value) }
emitter.close()
}
emitter.close()
}
private fun writeLink(emitter: XmlEmitter, link: GpxLink) {
emitter.open("link", "href" to link.href)
link.text?.let { emitter.leaf("text", it) }
emitter.close()
}
private fun writePointGroup(emitter: XmlEmitter, group: GpxPointGroup) {
emitter.selfClosing(
"osmand:group",
"name" to group.name,
"color" to group.color,
"icon" to group.icon,
"background" to group.background,
"pinned" to group.pinned,
"hidden" to group.hidden
)
}
}
/**
* Flat XML emitter with an element stack for closing tags. Text children
* escape </>/&; attributes additionally escape quotes. Null
* attribute values are omitted. Extension values ride as element text, so
* TEXT-only payloads (the phase-1 rule) round trip byte-for-byte after
* entity decoding.
*/
internal class XmlEmitter {
private val out = StringBuilder()
private val stack = ArrayDeque<String>()
override fun toString(): String = out.toString()
fun open(name: String, vararg attrs: Pair<String, String?>) {
openTag(name, attrs.toList(), selfClosing = false)
}
fun leaf(name: String, text: String) {
openTag(name, emptyList(), selfClosing = false)
out.append(escapeText(text))
close()
}
fun selfClosing(name: String, vararg attrs: Pair<String, String?>) {
openTag(name, attrs.toList(), selfClosing = true)
}
fun close() {
val name = stack.removeLast()
out.append("</").append(name).append('>')
}
private fun openTag(name: String, attrs: List<Pair<String, String?>>, selfClosing: Boolean) {
out.append('<').append(name)
for ((attr, value) in attrs) {
if (value == null) continue
out.append(' ').append(attr).append("=\"").append(escapeAttr(value)).append('"')
}
out.append(if (selfClosing) "/>" else ">")
if (!selfClosing) stack.addLast(name)
}
private fun escapeText(text: String): String = buildString(text.length) {
for (char in text) {
when (char) {
'&' -> append("&")
'<' -> append("<")
'>' -> append(">")
else -> append(char)
}
}
}
/**
* Attribute escaping also encodes the whitespace the XML spec normalizes
* away inside attribute values (newline/tab/CR) — otherwise round-tripped
* attribute text gains spurious spaces.
*/
private fun escapeAttr(text: String): String = buildString(text.length) {
for (char in text) {
when (char) {
'&' -> append("&")
'<' -> append("<")
'>' -> append(">")
'"' -> append(""")
'\n' -> append(" ")
'\r' -> append(" ")
'\t' -> append("	")
else -> append(char)
}
}
}
}The service
class GpxExportService(
private val repository: RoamRepository,
private val fileSystem: FileSystemInterface
) {
companion object {
const val GPX_MIME_TYPE = "application/gpx+xml"
const val GPX_EXTENSION = ".gpx"
}
/**
* Export located org nodes into one favorites-shaped GPX file. Bodies are
* read from the files (DB nodes don't carry them) with a per-file cache;
* groups derive from ARCOLOGY_GROUP ?? outline path ?? default bucket.
*/
suspend fun exportToGpx(
nodes: List<Pair<OrgNode, GeoCoordinate>>,
documentName: String,
documentDescription: String? = null
): GpxExport {
val warnings = mutableListOf<String>()
val bodyCache = HashMap<String, BodyCacheEntry>()
val waypoints = nodes.map { (node, coordinate) ->
buildWaypoint(node, coordinate, bodyCache, warnings)
}.sortedWith(compareBy({ it.type.orEmpty() }, { it.name.orEmpty() }))
val gpx = Gpx(
version = GPX_VERSION,
creator = GPX_CREATOR,
metadata = GpxMetadata(
name = documentName.takeIf { it.isNotBlank() },
description = documentDescription?.takeIf { it.isNotBlank() }
),
waypoints = waypoints,
pointGroups = pointGroupsFor(nodes, waypoints)
)
return GpxExport(GpxXmlWriter.write(gpx), warnings)
}
private suspend fun buildWaypoint(
node: OrgNode,
coordinate: GeoCoordinate,
bodyCache: HashMap<String, BodyCacheEntry>,
warnings: MutableList<String>
): GpxWaypoint {
val body = bodyFor(node, bodyCache, warnings)
val name = GpxOrgMapping.stripTagSpan(node.title.orEmpty()).ifBlank { node.id }
val extensions = LinkedHashMap<String, String>()
node.properties[GpxOrgMapping.PROP_OSMAND_ICON]?.let { extensions["icon"] = it }
node.properties[GpxOrgMapping.PROP_OSMAND_COLOR]?.let { extensions["color"] = it }
node.properties[GpxOrgMapping.PROP_OSMAND_BACKGROUND]?.let { extensions["background"] = it }
extensions[EXT_ARCOLOGY_ID] = node.id
body?.let { extensions[EXT_ARCOLOGY_ORG] = it }
// Unknown keys rehydrate verbatim after the parser's stored
// prefix-strip; keys from unknown namespaces carry a colon and cannot
// re-emit as osmand: children — skipped with a warning.
GpxOrgMapping.decodeExtensions(
node.properties[GpxOrgMapping.PROP_OSMAND_EXTENSIONS].orEmpty()
).forEach { (key, value) ->
if (':' in key) {
warnings.add("node ${node.id}: non-osmand prefixed extension '$key' skipped")
} else {
extensions[key] = value
}
}
return GpxWaypoint(
latitude = coordinate.latitude,
longitude = coordinate.longitude,
ele = coordinate.altitude,
time = node.properties[GpxOrgMapping.PROP_OSMAND_TIME],
name = name,
desc = body,
cmt = node.properties[GpxOrgMapping.PROP_OSMAND_CMT],
type = node.properties[GpxOrgMapping.PROP_ARCOLOGY_GROUP] ?: olpGroupFor(node),
extensions = extensions
)
}
/**
* The parent outline path as an OsmAnd group. The DB persists olp as a
* /-joined string and re-splits it on /, so the title's own slash-count
* inflates the tail — drop tokens equal to the title's segments first.
* Empty outline (top-level point) → null (no <type>, default bucket).
*/
private fun olpGroupFor(node: OrgNode): String? {
val titleTokens = node.title.orEmpty().split('/').size
val parentSegments = node.outlinePath.dropLast(titleTokens)
if (parentSegments.isEmpty()) return null
return parentSegments.joinToString("/")
}
private suspend fun pointGroupsFor(
nodes: List<Pair<OrgNode, GeoCoordinate>>,
waypoints: List<GpxWaypoint>
): List<GpxPointGroup> {
val merged = LinkedHashMap<String, GpxPointGroup>()
// sorted file order keeps appearance resolution deterministic under
// shuffled node input
val files = nodes.map { it.first.file }.distinct().sorted()
for (file in files) {
val encoded =
(repository.getFileProperties(file))[GpxOrgMapping.PROP_ARCOLOGY_POINTS_GROUPS]
?: continue
for (group in GpxOrgMapping.decodePointGroups(encoded)) {
merged.putIfAbsent(group.name, group)
}
}
// olp-derived groups (nodes outside import files) get plain entries
for (group in waypoints.mapNotNull { it.type }.toSortedSet()) {
merged.putIfAbsent(group, GpxPointGroup(name = group))
}
return merged.values.sortedBy { it.name }
}
private suspend fun bodyFor(
node: OrgNode,
bodyCache: HashMap<String, BodyCacheEntry>,
warnings: MutableList<String>
): String? {
val entry = bodyCache.getOrPut(node.file) {
BodyCacheEntry(
try {
fileSystem.readFile(node.file)
} catch (e: Exception) {
warnings.add("failed reading ${node.file}: ${e.message}")
null
}
)
}
val content = entry.content ?: return null
val pos = GpxOrgMapping.findHeadingPositionById(content, node.id)
if (pos == null) {
warnings.add("node ${node.id}: heading not found in ${node.file} — no body exported")
return null
}
val level = GpxOrgMapping.headingLevelAt(content, pos)
val nextPos = HeadingTextUtils.findNextHeadingPosition(content, pos, level) ?: content.length
val body = HeadingTextUtils.extractHeadingBody(content, pos, nextPos).trim('\n')
return body.ifBlank { null }
}
/** Per-file read result; a null content marks an unreadable file (warned once). */
private class BodyCacheEntry(val content: String?)
}GpxExportService.kt — assembled tangle target
package computer.whatthefuck.arcology.gis
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.editor.HeadingTextUtils
import computer.whatthefuck.arcology.indexer.FileSystemInterface
<<imp-export-models>>
<<imp-export-writer>>
<<imp-export-class>>GpxExportServiceTest
Round-trip gates per the phase-2 conventions: real `FlowFileIndexer` + in-memory repository, the phase-1 parser as the oracle, and (`gpx/favorites.gpx`) the real 110-point fixture — import→export→re-import must be a fixed point. Kotest property tests drive the emitter↔parser identity over hostile inputs.
package computer.whatthefuck.arcology.gis
import computer.whatthefuck.arcology.database.DatabaseTestUtils
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.FlowFileIndexer
import computer.whatthefuck.arcology.indexer.TestFileSystem
import computer.whatthefuck.arcology.parser.OrgFileParser
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.kotest.property.Arb
import io.kotest.property.arbitrary.Codepoint
import io.kotest.property.arbitrary.alphanumeric
import io.kotest.property.arbitrary.bind
import io.kotest.property.arbitrary.double
import io.kotest.property.arbitrary.element
import io.kotest.property.arbitrary.list
import io.kotest.property.arbitrary.map
import io.kotest.property.arbitrary.orNull
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
private val parser = GpxParser()
private const val TARGET_PATH = "imports/favorites.org"
/** Both services over one fresh pair of store doubles + a real indexer per test. */
private suspend fun <T> withServices(
block: suspend (
export: GpxExportService,
import: GpxImportService,
fileSystem: TestFileSystem,
repository: RoamRepository,
indexer: FlowFileIndexer
) -> T
): T {
val repository = DatabaseTestUtils.createTestRepository()
val fileSystem = TestFileSystem()
val indexer = FlowFileIndexer(repository, OrgFileParser(), fileSystem)
val export = GpxExportService(repository, fileSystem)
val import = GpxImportService(repository, fileSystem, indexer, "imports")
try {
return block(export, import, fileSystem, repository, indexer)
} finally {
DatabaseTestUtils.clearDatabase(repository)
}
}
/** One import file with a property-grouped point and an olp-derived sibling. */
private const val IMPORT_FILE_CONTENT =
":PROPERTIES:\n" +
":ID: file-node-1\n" +
":ARCOLOGY_POINTS_GROUPS: name=Coburg Hills and Cascades;color=#EECC22|name=;pinned=true\n" +
":END:\n" +
"#+title: favorites\n" +
"\n" +
"* Coburg Hills and Cascades\n" +
"** Coburg Ridge :Coburg_Hills_and_Cascades:\n" +
":PROPERTIES:\n" +
":ID: coburg-id\n" +
":GEO_COORDS: 44.1422919,-122.9749531,442.0\n" +
":ARCOLOGY_GROUP: Coburg Hills and Cascades\n" +
":OSMAND_TIME: 2024-12-30T00:13:16Z\n" +
":OSMAND_CMT: a comment\n" +
":OSMAND_ICON: park\n" +
":OSMAND_EXTENSIONS: amenity_origin=blm; address=here\n" +
":END:\n" +
"body with *markup* and [[https://example.com][a link]]\n" +
"** Meadow Point\n" +
":PROPERTIES:\n" +
":ID: meadow-id\n" +
":GEO_COORDS: 44.5,-123.5\n" +
":END:\n" +
"meadow body\n"
private val fixtureContent: String by lazy {
val stream = GpxExportServiceTest::class.java.getResourceAsStream("/gpx/favorites.gpx")
?: error("test resource gpx/favorites.gpx missing from the test classpath")
stream.use { it.readBytes().decodeToString() }
}
class GpxExportServiceTest : DescribeSpec({
describe("mapping") {
it("groups from property, tag-stripped names, raw org body") {
withServices { export, _, fs, repo, indexer ->
fs.addFile(TARGET_PATH, IMPORT_FILE_CONTENT)
indexer.indexFile(TARGET_PATH)
val result = export.exportToGpx(repo.getNodesWithLocation(), "favorites")
result.warnings shouldBe emptyList()
// tag spans never leak into OsmAnd names, and no org <link> is emitted
result.content shouldContain "<name>Coburg Ridge</name>"
result.content shouldNotContain "<link"
val gpx = parser.parse(result.content).gpx.shouldNotBeNull()
gpx.waypoints.size shouldBe 2
val coburg = gpx.waypoints.first { it.name == "Coburg Ridge" }
coburg.type shouldBe "Coburg Hills and Cascades"
coburg.latitude shouldBe 44.1422919
coburg.longitude shouldBe -122.9749531
coburg.ele shouldBe 442.0
coburg.time shouldBe "2024-12-30T00:13:16Z"
coburg.cmt shouldBe "a comment"
coburg.desc shouldBe "body with *markup* and [[https://example.com][a link]]"
coburg.extensions shouldBe mapOf(
"icon" to "park",
"arcology_id" to "coburg-id",
"arcology_org" to "body with *markup* and [[https://example.com][a link]]",
"amenity_origin" to "blm",
"address" to "here"
)
// olp sibling: no ARCOLOGY_GROUP property → group from outline path
val meadow = gpx.waypoints.first { it.name == "Meadow Point" }
meadow.type shouldBe "Coburg Hills and Cascades"
meadow.ele shouldBe null
meadow.desc shouldBe "meadow body"
// the file's appearance property rides the root points_groups block
// groups sort by name; the "" default bucket sorts first
gpx.pointGroups.map { it.name } shouldBe listOf("", "Coburg Hills and Cascades")
gpx.pointGroups.first { it.name == "" }.pinned shouldBe "true"
}
}
it("olp fallback groups are synthesized as plain entries") {
withServices { export, _, fs, repo, indexer ->
val journal =
":PROPERTIES:\n:ID: journals-file\n:END:\n#+title: 2026-10-10\n\n" +
"* Morning walk\n:PROPERTIES:\n:ID: walk-id\n" +
":GEO_COORDS: 44.0,-123.0\n:END:\nwalk body\n" +
"* 2026-10-10\n** Bench break\n:PROPERTIES:\n:ID: bench-id\n" +
":GEO_COORDS: 44.1,-123.1\n:END:\nbench body\n"
fs.addFile("journals/2026-10-10.org", journal)
indexer.indexFile("journals/2026-10-10.org")
val gpx = parser
.parse(export.exportToGpx(repo.getNodesWithLocation(), "journal spots").content)
.gpx
.shouldNotBeNull()
gpx.waypoints.first { it.name == "Morning walk" }.type.shouldBeNull()
gpx.waypoints.first { it.name == "Bench break" }.type shouldBe "2026-10-10"
// no appearances to round-trip in a journal file: only the
// groups derived from outline paths exist
gpx.pointGroups.map { it.name } shouldBe listOf("2026-10-10")
gpx.pointGroups.first { it.name == "2026-10-10" }.color.shouldBeNull()
}
}
it("byte-identical double export regardless of input order") {
withServices { export, _, fs, repo, indexer ->
fs.addFile(TARGET_PATH, IMPORT_FILE_CONTENT)
indexer.indexFile(TARGET_PATH)
val nodes = repo.getNodesWithLocation()
val first = export.exportToGpx(nodes, "favorites", "Exported from Arcology")
val second = export.exportToGpx(nodes, "favorites", "Exported from Arcology")
first.content shouldBe second.content
export.exportToGpx(nodes.reversed(), "favorites", "Exported from Arcology")
.content shouldBe first.content
}
}
}
describe("fixture round trip") {
it("import → export → re-import is a fixed point") {
withServices { export, import, _, repo, _ ->
val diff = import.plan("favorites", parser.parse(fixtureContent)).shouldNotBeNull()
import.apply(diff)
val exported = export.exportToGpx(
repo.getNodesWithLocation(),
"favorites",
"Exported from Arcology"
)
exported.warnings shouldBe emptyList()
parser.parse(exported.content).gpx.shouldNotBeNull().waypoints.size shouldBe 110
val again = import.plan("favorites", parser.parse(exported.content)).shouldNotBeNull()
again.updateCount shouldBe 0
again.movedCount shouldBe 0
again.orphans shouldBe emptyList()
again.warnings shouldBe emptyList()
val result = import.apply(again)
result.imported shouldBe 0
result.unchanged shouldBe 110
}
}
}
describe("writer ↔ parser property") {
// XML text can't hold raw control chars beyond tab/newline/CR — the
// value alphabet is printable ASCII plus a few unicode samples, never
// empty/blank (XML readers drop blanks and the property would drift).
// No leading/trailing whitespace either: element text is trimmed by
// the reader (pinned OsmAnd behavior), so padded values would not
// round-trip by design.
val textArb = Arb.list(
Arb.element(
listOf(
'a', 'Z', '0', '9', '_', ' ', '-', ':', ';', '=', '\t', '\n', '&',
'<', '>', '"', '/', '\\', 'é', '中', 'ö'
)
),
1..24
).map { chars -> chars.joinToString("").trim().ifBlank { "x" } }
val keyArb = Arb.string(1..10, Codepoint.alphanumeric())
val extArb = Arb.map(keyArb, textArb, minSize = 0, maxSize = 3)
val linkArb = Arb.bind(textArb, textArb.orNull()) { href, text ->
GpxLink(href, text)
}
val groupArb = Arb.bind(
Arb.string(1..10, Codepoint.alphanumeric()),
textArb.orNull(),
textArb.orNull(),
textArb.orNull(),
Arb.element(listOf("true", "false")).orNull(),
Arb.element(listOf("true", "false")).orNull()
) { name, color, icon, background, pinned, hidden ->
GpxPointGroup(name, color, icon, background, pinned, hidden)
}
val waypointArb = Arb.bind(
Arb.double(-90.0, 90.0),
Arb.double(-180.0, 180.0),
Arb.double(-500.0, 9000.0).orNull(),
textArb.orNull(),
textArb.orNull(),
textArb.orNull(),
textArb.orNull(),
textArb.orNull(),
extArb,
Arb.list(linkArb, 0..2)
) { lat, lon, ele, time, name, desc, cmt, type, extensions, links ->
GpxWaypoint(lat, lon, ele, time, name, desc, cmt, type, links, extensions)
}
val gpxArb = Arb.bind(
Arb.string(1..10, Codepoint.alphanumeric()),
textArb.orNull(),
textArb.orNull(),
extArb,
Arb.map(keyArb, textArb, minSize = 0, maxSize = 2),
Arb.list(waypointArb, 0..6),
Arb.list(groupArb, 0..4)
) { creator, metaName, metaDesc, metaExts, rootExts, waypoints, groups ->
Gpx(
version = GPX_VERSION,
creator = creator,
metadata = GpxMetadata(
name = metaName,
description = metaDesc,
extensions = metaExts
),
waypoints = waypoints,
rootExtensions = rootExts,
pointGroups = groups
)
}
it("model → xml → model is identity for hostile inputs") {
checkAll(200, gpxArb) { gpx ->
parser.parse(GpxXmlWriter.write(gpx)).gpx shouldBe gpx
}
}
}
})CLI entry point: gpx-export
The export service's CLI face, symmetric with gpx-import: exports every GEO_COORDS node in the database into one favorites-shaped file. Group derivation and determinism are the service's; --out defaults alongside the org root (gpx files aren't indexed, so parking the artifact in-tree is harmless — the app share route uses the cache dir instead). Tangles as src/jvmMain/kotlin/computer/whatthefuck/arcology/gis/GpxExportCommand.kt and registers in =ArcologyCli='s subcommand list (app/cli.org).
package computer.whatthefuck.arcology.gis
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.database.RoamRepositoryImpl
import computer.whatthefuck.arcology.indexer.JvmFileSystem
import java.io.File
import kotlin.io.path.Path
import kotlinx.coroutines.runBlocking
/**
* CLI entry point for GPX export:
*
* arcology gpx-export --org-dir ~/org --db ~/org/arcology.db
* arcology gpx-export --org-dir ~/org --out ~/shared/favorites.gpx
*
* Exports every located org node into one deterministic favorites-shaped
* file (see =GpxExportService=). No located nodes → writes nothing, exits.
*/
class GpxExportCommand : CliktCommand(
name = "gpx-export",
help = "Export located org nodes to a favorites-shaped GPX file"
) {
private val orgDir by option("--org-dir", help = "Org directory root").default(".")
private val dbPath by option("--db", help = "Database path").default("arcology.db")
private val out by option("--out", help = "Output gpx path (default: <org-dir>/favorites-export.gpx)")
private val name by option("--name", help = "GPX metadata name").default("favorites")
override fun run() = runBlocking {
val expandedOrg = expandTilde(orgDir)
val databasePath = if (dbPath == "arcology.db") {
File(expandedOrg, "arcology.db").absolutePath
} else {
expandTilde(dbPath)
}
val outFile = File(expandTilde(out ?: File(expandedOrg, "favorites-export.gpx").path))
val fileSystem = JvmFileSystem(Path(expandedOrg))
val repository = RoamRepositoryImpl(DatabaseFactory.createDatabase(databasePath))
val service = GpxExportService(repository, fileSystem)
val nodes = repository.getNodesWithLocation()
if (nodes.isEmpty()) {
echo("no located nodes in the database — nothing to export", err = true)
return@runBlocking
}
val result = service.exportToGpx(
nodes = nodes,
documentName = name,
documentDescription = "Exported from arcology2go"
)
outFile.writeText(result.content)
echo("exported ${nodes.size} points → ${outFile.absolutePath}")
result.warnings.forEach { echo("warning: $it", err = true) }
}
private fun expandTilde(path: String): String = when {
path == "~" -> System.getProperty("user.home")
path.startsWith("~/") -> System.getProperty("user.home") + path.substring(1)
else -> path
}
}Import entry points: share intent wiring
Receiver already exists: MainActivity takes ACTION_SEND/SEND_MULTIPLE for =/= and parseSharedIntent (app/bootstrap.org:~895-1058) routes EXTRA_STREAM URIs into SharedContent.Uris. Phase-2 state:
DONE 2026-10-09 — sniff-based share branch: the ArcologyApp share-effect reads each shared URI through
Context.readSharedText(roam/models.org) andGpxParser; when the share is URIs-only and every URI sniffs as a favorites gpx, it routes toScreen.GpxImportinstead of the capture flow. Any text, any mixed share → today's capture flow (deterministic, nothing silently dropped).*DONE — manifest: dedicated SEND + ACTION_VIEW filters for
application/gpx+xml. NOTE:AndroidManifest.xmlis hand-maintained — NOT from app/build.org (that file is build-system only); direct edits + this note fix the earlier wrong pointer. - DONE — no sync watcher: automatic import skips the review screen, and review is load-bearing for merge/removal decisions until the idempotency policy has months of runtime behind it. - TODO — sub-heading generation* off an attached gpx = the import service targeting the attaching node's subtree as the import file location. Later phase: the service is URI-agnostic so the wiring stays thin.
ImportReviewScreen v1
Summary counts (new/unchanged/moved/orphaned) per source + orphan confirm checkboxes folded into the single Apply action (orphans delete only when selected — never auto-executed) + applied summary. Iterate from there.
ImportReviewViewModel
Constructs the service per plan/apply run against the CURRENT selected tree (the FlashcardService factory pattern — DI wires it in app/bootstrap.org's app-module block). Diffing multiple shared files happens per-URI; each carries its own diff and orphans.
package computer.whatthefuck.arcology.app.viewmodel
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.gis.GpxImportDiff
import computer.whatthefuck.arcology.gis.GpxImportResult
import computer.whatthefuck.arcology.gis.GpxImportService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Review-screen state machine for GPX imports (maps/import-export.org).
* plan() per shared URI; apply() executes every prepared diff, deleting
* orphans only for the ids the user confirmed.
*/
sealed interface ImportReviewUiState {
data object Idle : ImportReviewUiState
data object Loading : ImportReviewUiState
/** Planned diffs + the sources that turned out unreadable or non-importable. */
data class Ready(
val diffs: List<GpxImportDiff>,
val failedSources: List<String> = emptyList()
) : ImportReviewUiState
/** All diffs applied. */
data class Applied(val results: List<GpxImportResult>) : ImportReviewUiState
data class Failure(
val message: String,
val failedSources: List<String> = emptyList()
) : ImportReviewUiState
}
class ImportReviewViewModel(
private val appPreferences: AppPreferencesInterface,
/** Builds the service against the CURRENT tree; re-built per plan/apply run. */
private val serviceFactory: (treeUri: Uri) -> GpxImportService,
/**
* Where prepare/apply run. Defaults off-main: the service does file IO and
* OrgLexer re-parses per property write — hundreds of points of that on
* the main dispatcher is the ANR the import screen shipped with. Tests
* inject the test dispatcher so the work stays advanced-until-idle-able.
*/
private val backgroundDispatcher: kotlinx.coroutines.CoroutineDispatcher =
kotlinx.coroutines.Dispatchers.Default
) : ViewModel() {
private val _state = MutableStateFlow<ImportReviewUiState>(ImportReviewUiState.Idle)
val state: StateFlow<ImportReviewUiState> = _state.asStateFlow()
/** Plan every shared gpx URI into a diff; unreadable/non-gpx sources are reported, not fatal. */
fun prepare(uris: List<String>) {
if (uris.isEmpty()) return
viewModelScope.launch(backgroundDispatcher) {
runCatching {
_state.value = ImportReviewUiState.Loading
val treeUri = appPreferences.getSelectedDirectoryUri()
?: error("No org directory selected — finish onboarding first")
val service = serviceFactory(treeUri)
val diffs = mutableListOf<GpxImportDiff>()
val failed = mutableListOf<String>()
for (uri in uris) {
try {
val parsed = service.readGpx(uri)
val diff = service.plan(displayName(uri), parsed, sourcePath = uri)
if (diff != null) diffs.add(diff) else failed.add(displayName(uri))
} catch (e: Exception) {
failed.add(displayName(uri))
}
}
_state.value = when {
diffs.isEmpty() ->
ImportReviewUiState.Failure("No importable favorites files found", failed)
else ->
ImportReviewUiState.Ready(diffs, failed)
}
}.onFailure { e ->
_state.value =
ImportReviewUiState.Failure(e.message ?: "planning failed: ${e::class.simpleName}")
}
}
}
/**
* Apply every prepared diff, narrowed to the user's card selection when
* [selectedNodeIds] is given (the review screen's per-point checkboxes;
* diffs left with no selected entries are skipped entirely). Orphan
* deletions ride the same idempotent apply, restricted per-diff to that
* file's own orphans.
*/
fun applyImport(selectedNodeIds: Set<String>? = null, confirmedOrphans: Set<String> = emptySet()) {
val diffs = (_state.value as? ImportReviewUiState.Ready)?.diffs ?: return
viewModelScope.launch(backgroundDispatcher) {
// the spinner frame the first version never got a chance to render:
// apply used to run the whole pipeline on Main
runCatching {
_state.value = ImportReviewUiState.Loading
val treeUri = appPreferences.getSelectedDirectoryUri()
?: error("No org directory selected")
val service = serviceFactory(treeUri)
val results = diffs.mapNotNull { diff ->
val entries = selectedNodeIds
?.let { selected -> diff.entries.filter { it.nodeId in selected } }
?: diff.entries
if (entries.isEmpty()) return@mapNotNull null
val narrowed = diff.copy(entries = entries)
service.apply(
narrowed,
confirmedOrphans.intersect(narrowed.orphans.map { it.nodeId }.toSet())
)
}
_state.value = ImportReviewUiState.Applied(results)
}.onFailure { e ->
_state.value =
ImportReviewUiState.Failure(e.message ?: "import failed: ${e::class.simpleName}")
}
}
}
fun reset() {
_state.value = ImportReviewUiState.Idle
}
/**
* Human-ish source label from a content URI or path. Uri.decode is
* android.jar stubbed in JVM unit tests — it throws there, so it is
* guarded and falls through to the metadata-name fallback in plan().
*/
private fun displayName(uri: String): String =
runCatching { Uri.decode(uri.substringAfterLast('/')) }
.getOrNull()
?.removeSuffix(".gpx")
?.ifBlank { "favorites" }
?: "favorites"
}ImportReviewViewModelTest
App-side ViewModel tests per the editor/viewmodel.org conventions (test doubles over suspend-function mocks, mocked Main dispatcher). The service is REAL here, wired to TestFileSystem + RoamRepositoryTestDouble + a no-op indexer — only URI strings and the tree URI (a mockk<Uri>, unavailable in JVM per the test axioms) are fakes.
package computer.whatthefuck.arcology.app.viewmodel
import android.net.Uri
import android.util.Log
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.testutils.AppPreferencesTestDouble
import computer.whatthefuck.arcology.app.testutils.RoamRepositoryTestDouble
import computer.whatthefuck.arcology.gis.GpxImportService
import computer.whatthefuck.arcology.gis.OSMAND_GPX_NAMESPACE
import computer.whatthefuck.arcology.gis.WaypointId
import computer.whatthefuck.arcology.indexer.FileIndexingService
import computer.whatthefuck.arcology.indexer.FileIndexResult
import computer.whatthefuck.arcology.indexer.FileSystemInterface
import computer.whatthefuck.arcology.indexer.IndexProgress
import computer.whatthefuck.arcology.indexer.IndexResult
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
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.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import kotlinx.datetime.Instant
private const val GPX_URI = "content://test/favorites.gpx"
/**
* App-test filesystem fake (TestFileSystem lives in the commonTest source
* set, invisible here): a string-keyed map covering the read/write surface
* GpxImportService actually uses.
*/
private class CannedFiles(private val files: MutableMap<String, String> = mutableMapOf()) : FileSystemInterface {
fun addFile(path: String, content: String) {
files[path] = content
}
override suspend fun fileExists(path: String): Boolean = files.containsKey(path)
override suspend fun readFile(path: String): String =
files[path] ?: throw java.io.FileNotFoundException(path)
override suspend fun writeFile(path: String, content: String) {
files[path] = content
}
override suspend fun getLastModified(path: String): Instant = Instant.fromEpochSeconds(0)
override fun listOrgFiles(path: String, recursive: Boolean): Flow<String> = emptyFlow()
override suspend fun readIgnoreFile(rootPath: String): String? = null
}
/** No-op indexer: the VM only needs apply's write+index round trip to not throw. */
private class FakeIndexingService : FileIndexingService {
override fun indexDirectoryFlow(path: String, recursive: Boolean): Flow<IndexProgress> = emptyFlow()
override suspend fun indexDirectory(path: String, recursive: Boolean): IndexResult =
IndexResult(totalFiles = 0, successful = 0, failed = 0, skipped = 0)
override suspend fun indexFile(filePath: String): FileIndexResult =
FileIndexResult.Success(filePath, nodesCount = 0, linksCount = 0, tagsCount = 0)
override suspend fun removeFile(filePath: String): Boolean = true
}
@OptIn(ExperimentalCoroutinesApi::class)
class ImportReviewViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private var fileSystem: CannedFiles = CannedFiles()
private lateinit var repository: RoamRepositoryTestDouble
private lateinit var appPreferences: AppPreferencesTestDouble
private lateinit var viewModel: ImportReviewViewModel
private val favoritesGpx =
"<gpx version=\"1.1\" xmlns:osmand=\"$OSMAND_GPX_NAMESPACE\">" +
"<metadata><name>favorites</name></metadata>" +
"<wpt lat=\"44.0\" lon=\"-123.0\"><name>A</name><type>g</type></wpt>" +
"</gpx>"
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.e(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0
fileSystem.addFile(GPX_URI, favoritesGpx)
repository = RoamRepositoryTestDouble()
// Uri is unavailable on the JVM — a bare mock as the tree handle
appPreferences = AppPreferencesTestDouble(selectedDirectoryUri = mockk<Uri>())
val indexer = FakeIndexingService()
viewModel = ImportReviewViewModel(
appPreferences = appPreferences,
serviceFactory = { _ ->
GpxImportService(repository, fileSystem, indexer, AppPreferences.DEFAULT_IMPORTS_SUBDIRECTORY)
},
backgroundDispatcher = testDispatcher
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// runTest(testDispatcher) — viewModelScope launches on the Main dispatcher
// set in setUp, so the Main scheduler is the one that must be advanced.
@Test
fun `prepare plans the shared gpx into a ready diff`() = runTest(testDispatcher) {
viewModel.prepare(listOf(GPX_URI))
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state is ImportReviewUiState.Ready)
val ready = state as ImportReviewUiState.Ready
assertEquals(1, ready.diffs.size)
assertEquals(1, ready.diffs.first().newCount)
assertEquals(emptyList<String>(), ready.failedSources)
}
@Test
fun `applyImport moves Ready to Applied`() = runTest(testDispatcher) {
viewModel.prepare(listOf(GPX_URI))
advanceUntilIdle()
viewModel.applyImport()
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state is ImportReviewUiState.Applied)
val applied = state as ImportReviewUiState.Applied
assertEquals(1, applied.results.size)
assertEquals(1, applied.results.first().imported)
assertEquals("gpx_imports/favorites.org", applied.results.first().file)
}
@Test
fun `applyImport narrows to the selected points`() = runTest(testDispatcher) {
// two-point gpx: A gets imported, B unchecked on the cards
val idA = WaypointId.forWaypoint("g", "A")
fileSystem.addFile(
GPX_URI,
"<gpx version=\"1.1\" xmlns:osmand=\"$OSMAND_GPX_NAMESPACE\">" +
"<metadata><name>favorites</name></metadata>" +
"<wpt lat=\"44.0\" lon=\"-123.0\"><name>A</name><type>g</type></wpt>" +
"<wpt lat=\"45.0\" lon=\"-124.0\"><name>B</name><type>g</type></wpt>" +
"</gpx>"
)
viewModel.prepare(listOf(GPX_URI))
advanceUntilIdle()
val ready = viewModel.state.value as ImportReviewUiState.Ready
assertEquals(2, ready.diffs.first().entries.size)
viewModel.applyImport(selectedNodeIds = setOf(idA))
advanceUntilIdle()
assertTrue(viewModel.state.value is ImportReviewUiState.Applied)
val applied = viewModel.state.value as ImportReviewUiState.Applied
assertEquals(1, applied.results.size)
assertEquals(1, applied.results.first().imported) // only A
}
@Test
fun `prepare with non-importable content fails`() = runTest(testDispatcher) {
fileSystem.addFile("content://test/bogus.gpx", "<html><body>nope</body></html>")
viewModel.prepare(listOf("content://test/bogus.gpx"))
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state is ImportReviewUiState.Failure)
}
@Test
fun `prepare without a selected org directory fails fast`() = runTest(testDispatcher) {
val noDirectory = ImportReviewViewModel(
appPreferences = AppPreferencesTestDouble(selectedDirectoryUri = null),
serviceFactory = { _ -> error("service must not be built without a tree") },
backgroundDispatcher = testDispatcher
)
noDirectory.prepare(listOf(GPX_URI))
advanceUntilIdle()
val state = noDirectory.state.value
assertTrue(state is ImportReviewUiState.Failure)
}
}package computer.whatthefuck.arcology.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.viewmodel.ImportReviewUiState
import computer.whatthefuck.arcology.app.viewmodel.ImportReviewViewModel
import computer.whatthefuck.arcology.gis.GpxImportAction
import computer.whatthefuck.arcology.gis.GpxImportDiff
import computer.whatthefuck.arcology.gis.GpxImportEntry
import computer.whatthefuck.arcology.gis.GpxOrphan
import org.koin.androidx.compose.koinViewModel
/**
* Import review: a diff surface, not an import gate (maps/import-export.org).
* Every proposed point is a selectable card in a scrolling list (unchecked
* cards are skipped by the Apply action); per-file summaries group the cards;
* orphans — points the gpx no longer covers — are suggested deletions, off by
* default and only deleted when confirmed. v2.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportReviewScreen(
uris: List<String>,
onDone: () -> Unit,
onBack: () -> Unit
) {
val viewModel: ImportReviewViewModel = koinViewModel()
val state by viewModel.state.collectAsState()
val selectedEntries = remember { mutableStateMapOf<String, Boolean>() }
val selectedOrphans = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(uris) {
if (uris.isNotEmpty()) {
selectedEntries.clear()
selectedOrphans.clear()
viewModel.prepare(uris)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Import GPX") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
when (val current = state) {
is ImportReviewUiState.Idle, is ImportReviewUiState.Loading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
is ImportReviewUiState.Failure -> {
Text(
current.message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
current.failedSources.forEach { source ->
Text("• $source", style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(16.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
Text("Close")
}
}
is ImportReviewUiState.Ready -> {
val allEntries = current.diffs.flatMap { it.entries }
val selectedEntryIds = allEntries
.filter { selectedEntries[it.nodeId] ?: true }
.map { it.nodeId }
.toSet()
val selectedOrphanIds = selectedOrphans.filterValues { it }.keys
Text(
"${current.diffs.size} file(s): " +
"${allEntries.count { it.action == GpxImportAction.NEW }} new, " +
"${allEntries.count { it.action == GpxImportAction.UPDATE }} updated, " +
"${allEntries.count { it.action == GpxImportAction.MOVED }} moved, " +
"${allEntries.count { it.action == GpxImportAction.UNCHANGED }} unchanged, " +
"${current.diffs.sumOf { it.orphans.size }} orphaned",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(8.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
current.diffs.forEach { diff ->
item(key = "file-" + diff.targetFile) {
ImportFileCard(diff)
}
items(diff.entries, key = { it.nodeId }) { entry ->
ImportEntryCard(entry, selectedEntries)
}
items(diff.orphans, key = { "orphan-" + it.nodeId }) { orphan ->
ImportOrphanRow(orphan, selectedOrphans)
}
}
}
if (current.failedSources.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
"Skipped sources: " + current.failedSources.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(8.dp))
Button(
onClick = {
viewModel.applyImport(
selectedNodeIds = selectedEntryIds,
confirmedOrphans = selectedOrphanIds
)
selectedEntries.clear()
selectedOrphans.clear()
},
enabled = selectedEntryIds.isNotEmpty() || selectedOrphanIds.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
) {
Text(
"Import " + selectedEntryIds.size + " point(s)" +
(if (selectedOrphanIds.isNotEmpty()) " + delete " + selectedOrphanIds.size + " orphan(s)" else "")
)
}
}
is ImportReviewUiState.Applied -> {
current.results.forEach { result ->
Text(
result.file + ": imported " + result.imported + ", " +
"unchanged " + result.unchanged + ", removed " + result.orphansDeleted,
style = MaterialTheme.typography.bodyMedium
)
result.warnings.forEach { warning ->
Text(
"⚠ $warning",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(8.dp))
Text(
"The map screen picks up imported points after reindexing.",
style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(16.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
Text("Done")
}
}
}
}
}
}
/** Per-file summary card heading the file's entry list. */
@Composable
private fun ImportFileCard(diff: GpxImportDiff) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Text(diff.sourceName, style = MaterialTheme.typography.titleMedium)
Text(
"→ " + diff.targetFile,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
diff.warnings.forEach { warning ->
Text(
"⚠ $warning",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
/** One proposed point: select to import; chip line says what would happen. */
@Composable
private fun ImportEntryCard(
entry: GpxImportEntry,
selected: SnapshotStateMap<String, Boolean>
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selected[entry.nodeId] ?: true,
onCheckedChange = { checked -> selected[entry.nodeId] = checked }
)
Column(modifier = Modifier.weight(1f)) {
Text(
(entry.wpt.name ?: "unnamed") + (entry.wpt.type?.let { " (" + it + ")" } ?: ""),
style = MaterialTheme.typography.bodyMedium,
maxLines = 2
)
Text(
actionLabel(entry),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}
}
/** Orphan row: an unchecked deletion suggestion. */
@Composable
private fun ImportOrphanRow(
orphan: GpxOrphan,
selectedOrphans: SnapshotStateMap<String, Boolean>
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedOrphans[orphan.nodeId] ?: false,
onCheckedChange = { checked -> selectedOrphans[orphan.nodeId] = checked }
)
Column(modifier = Modifier.weight(1f)) {
Text(
orphan.title ?: orphan.nodeId,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2
)
Text(
"missing from the gpx — delete" +
(orphan.outlinePath.takeIf { it.isNotEmpty() }
?.let { " · " + it.joinToString("/") } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}
}
private fun actionLabel(entry: GpxImportEntry): String {
val base = when (entry.action) {
GpxImportAction.NEW -> "new"
GpxImportAction.UPDATE -> "update"
GpxImportAction.MOVED -> "moved"
GpxImportAction.UNCHANGED -> "unchanged"
} + (entry.outlinePath.takeIf { it.isNotEmpty() }?.let { " → " + it.joinToString("/") } ?: "")
return entry.changedFields.takeIf { it.isNotEmpty() }
?.let { base + " · changed: " + it.joinToString(", ") }
?: base
}GpxExportService — design notes (implemented phase 3, 2026-10-10)
The implementation lives in "* GpxExportService (phase 3, 2026-10-10)" above. The settled-design notes that still govern it:
Determinism requirement (realized)
Stable key ordering, no regenerated timestamps, no changing creator or metadata → OsmAnd re-import is idempotent while nothing changed (FavouritePoint.equals compares all of it). Byte-identical double-export plus the fixture import→export→re-import fixed point are the tests — both green.
Group-scoped replace, not merge (future, phase 4)
Designate synced groups via an org-side flag (:ARCOLOGY_SYNC:, group-scoped). Export update flow: removeFavoriteGroup + addFavoriteGroup via AIDL, or the file route: wipe that one group in OsmAnd's Favorites UI first, then import. Destructive radius = that synced group only. Synced points never live in default/Personal/Home/Work/Parking groups (FavoriteGroup.fromPointsGroup pins appearance defaults there) — enforced by never exporting into the default or Personal groups.
Outline path → group mapping (DECIDED 2026-10-10: property first, olp fallback)
Groups derive per point: ARCOLOGY_GROUP property (imported points carry it), else the node's parent outline path joined with /, else no <type> (OsmAnd default bucket) — the =""=-derived olp polluter case (journal dates) was accepted by user call; appearance entries only come from ARCOLOGY_POINTS_GROUPS file properties. Tags beyond the group tag already live on the heading and ride the body; OsmAnd favorites have NO file hierarchy — the Favorites-UI tree is faked by / characters in group names.
Tracks/routes
Export never emits them — a favorites.gpx with trk/trkseg gets imported favorites-only anyway (FavoritesImportTask reads getPointsList only). Tracks belong as plain .gpx attachments on a heading.
Export routes (both landed)
App share route:
MapViewModel.exportToGpx(context)beside where the KML button lived — same filtered node set, cacheDir + FileProvider + share intent,application/gpx+xml. Suspend, launched from arememberCoroutineScope(find/map-screen.org).CLI:
arcology gpx-export(symmetric withgpx-import).KML export was REMOVED 2026-10-10 (user call):
KmlModels,KmlExportService,GeoCoordinate.toKmlCoordinatesand the map screen's KML button are gone;find/map-screen.orgwas KML's org home.
Transport design — three shapes, ranked
Share intent (phase 1): above. Manual, one-way.
AIDL push (phase 2 — after device experiments):
importGpx(FileProvider URI or raw data + destination path),addFavoriteupdateFavoriteremoveFavoriteaddFavoriteGroupremoveFavoriteGroup. Hard limits confirmed from source:addFavoritehas no icon parameter, colors are ten palette names only ("red".."brown"), and there is NO favorites read API at all — AIDL is publish-only, arcology stays the source of truth for the synced set. That missing read API is also what kills CRDT/OT app-to-app sync.Custom OsmAnd plugin CRDT fork: parked. A fork of OsmAnd is the only true bidirectional path and it's a lifestyle choice, not a phase.
Unverified cheap trick (device experiment phase 4)
importGpx destination paths may contain dirs, and OsmAnd loads favorites*.gpx out of files/favorites/ as external groups at startup (loadExternalGroups) — push there via AIDL/SAF and export becomes silent background sync without touching the OsmAnd UI. Area had the import-path-traversal CVE-2026-65996 (fixed); run as device experiment, don't default to it.
Attachments round-trip
Org-side photos survive any round trip because they never enter the gpx: child heading tagged :ATTACH: under the POI heading holding file: links; FlowFileIndexer collects these into published_attachments, ArcologyAttachmentPlugin carries crushed variants, LinkResolver.resolveAttachment() resolves, ATTACHMENT_IMAGE_EXTENSIONS render as <img>. Gpx export regenerates desc from org every time; <link href> gets the first published attachment URL (phone context menu shows it with connectivity).
OsmAnd-side media has no gpx channel (OSF zip only — deferred v2 receiver). V1 import: carry any <link href> value through to the org body (plain link, not ATTACH); review screen lists points that had links so nothing silently drops.
Open point (OPEN)
Where does the <link> → body link land when org body is org-owned and would be clobbered on refresh? Body policy restore-vs-passthrough interaction needs settling at implementation time.
Device experiments (phase 4, manual on-phone)
favorites.gpx → edit in OsmAnd → re-export diff with our extensions attached (behavior check of the round-trip contract; source-verified already).
importGpx-to-favorites-dir silent-sync trick (above).
Tangle targets
Phase 2 tangles (2026-10-10):
src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxImportService.kt— diff models,htmlToPlainText, service, helperssrc/commonTest/kotlin/computer/whatthefuck/arcology/gis/GpxImportServiceTest.ktsrc/jvmMain/kotlin/computer/whatthefuck/arcology/gis/GpxImportCommand.kt— registered inArcologyCli(app/cli.org) Phase 3 adds:src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxExportService.kt—GpxExportresult,GpxXmlWriteremitter, servicesrc/commonTest/kotlin/computer/whatthefuck/arcology/gis/GpxExportServiceTest.ktsrc/jvmMain/kotlin/computer/whatthefuck/arcology/gis/GpxExportCommand.ktPhase 3 removes:src/commonMain/kotlin/computer/whatthefuck/arcology/export/{KmlExportService,KmlModels}.kt(find/map-screen.orgwas their org home), anddomain/GeoCoordinate.toKmlCoordinateslost its last consumer.src/commonMain/kotlin/computer/whatthefuck/arcology/indexer/FileIndexingService.kt—ensureFileon the shared contract (roam/indexer.org)src/{androidMain,jvmMain}/.../indexer/{AndroidFileSystem,JvmFileSystem}.kt— create-if-absent overrides (roam/indexer-platform.org)src/androidMain/.../domain/PendingAttachment.android.kt—Context.readSharedText(roam/models.org)app/src/main/android/...AndroidManifest.xml— gpx SEND/VIEW filters — hand-maintained, direct edit (NOT from app/build.org)app/src/main/.../app/data/AppPreferences.kt— imports subdirectory pref (app/data.org)app/src/main/.../app/{di/AppModule,MainActivity,ui/navigation/Screen}.kt— DI, sniff branch, route (app/bootstrap.org)app/src/main/.../app/viewmodel/ImportReviewViewModel.kt+ui/screens/ImportReviewScreen.kt+app/src/test/.../ImportReviewViewModelTest.kt(this file)
Phase 3 remains: src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxExportService.kt.
Tests
Phase 2 (DONE 2026-10-10): GpxImportServiceTest (19 kotest cases — fresh import/re-import idempotency/drift/orphans/moved/guards/html naming) and the app-side ImportReviewViewModelTest (state machine over doubles).
Phase 3 (DONE 2026-10-10): GpxExportServiceTest — mapping cases (property/olp group derivation, tag-stripped names, raw-markup desc, byte-identical double export under shuffled input), the 110-point fixture import→export→re-import fixed point, and a writer↔parser identity property test over hostile inputs (entity escaping, whitespace normalization, blank/trimmed-text generators). The KML-shaped 0%-coverage gap retired with KML itself. Remaining (with the exporter now real): the :ARCOLOGY_SYNC: group-scoped AIDL/file-route flow is phase-4 device-experiment territory.