Arcology Engine

Maps — GPX Data Models, Parser & Deterministic Node IDs

Contents

Status: phase-1 data layer DONE (2026-10-09).

GeoCoordinate, GpxModels, GpxParser, WaypointId, GpxOrgMapping are tangled and tested, with the real favorites fixture as a test resource (roam/fixtures.org). Import/export services and the review screen remain in maps/import-export.org (phases 2–3); round-trip identity property tests land with the exporter.

The shared data layer for the OsmAnd bridge: GPX 1.1 model, parser, and the deterministic waypoint-ID scheme — all in commonMain, consumed by import and export alike. Modeled after the KML shapes that preceded it (removed 2026-10-10): xmlutil @Serializable annotations on plain-field models, with the awkward extension surfaces hand-parsed instead.

GeoCoordinate — shared geographic primitive

GeoCoordinate moved here in a fold (2026-10-09): the maps cluster is the home of the geospatial data layer, and the class now tangles as its own file in the domain package. Same-package split, so the ~10 extant consumers (MapUtils, LocationPickerScreen, CaptureService, RoamRepository's getNodesWithLocation(), the GPX services) import computer.whatthefuck.arcology.domain.GeoCoordinate unchanged. roam/models.org holds a stub where the fragment lived; its ModelsTest never had GeoCoordinate coverage, so this file bootstraps it below instead of carrying a carve-out over.

kotlin#+name: geo-coordinate-class:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/domain/GeoCoordinate.kt
package computer.whatthefuck.arcology.domain

/**
 * Geographic coordinate with latitude and longitude.
 * Used for storing and querying location data from GEO_COORDS heading properties.
 */
data class GeoCoordinate(
    val latitude: Double,
    val longitude: Double,
    /**
     * Optional altitude in meters, carried from GPX <ele> elements or
     * 3-part property values. Elevation is identity-relevant on the
     * OsmAnd side (favorites dedupe compares it) — it is never silently
     * rounded to or dropped at 0.
     */
    val altitude: Double? = null
) {
    companion object {
        /**
         * Standard property key for storing coordinates in org-mode headings.
         */
        const val PROPERTY_KEY = "GEO_COORDS"

        /**
         * Parse a coordinate string in "latitude,longitude" (2-part) or
         * "latitude,longitude,altitude" (3-part) format.
         * Returns null if the string is not a valid coordinate pair —
         * including when a non-finite (NaN/Infinity) altitude component
         * is present: garbage altitude makes the whole value garbage.
         */
        fun parse(value: String): GeoCoordinate? {
            val parts = value.trim().split(",")
            if (parts.size !in 2..3) return null
            val lat = parts[0].trim().toDoubleOrNull() ?: return null
            val lon = parts[1].trim().toDoubleOrNull() ?: return null
            val altitude = if (parts.size == 3) {
                val alt = parts[2].trim().toDoubleOrNull() ?: return null
                if (!alt.isFinite()) return null
                alt
            } else {
                null
            }
            // Basic validation: latitude must be -90 to 90, longitude -180 to 180
            if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) return null
            return GeoCoordinate(lat, lon, altitude)
        }
    }

    /**
     * Serialize to "latitude,longitude" format for storage in org-mode properties.
     * Includes the altitude as a third component when present — pre-altitude
     * property values and readers stay backward-compatible.
     */
    fun toPropertyValue(): String =
        if (altitude != null) "$latitude,$longitude,$altitude" else "$latitude,$longitude"
}

GeoCoordinateTest

Bootstrapped coverage for the class when it moved (the old ModelsTest never had it). Behavior of the pre-move class is pinned here before the altitude extension changes parse in the next section.

kotlin#+name: geo-coordinate-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/domain/GeoCoordinateTest.kt
package computer.whatthefuck.arcology.domain

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe

class GeoCoordinateTest : DescribeSpec({

    describe("parse") {
        it("parses a valid coordinate pair") {
            GeoCoordinate.parse("37.7749,-122.4194") shouldBe GeoCoordinate(37.7749, -122.4194)
        }

        it("trims surrounding whitespace and per-part whitespace") {
            GeoCoordinate.parse("  44.0 , -123.1  ") shouldBe GeoCoordinate(44.0, -123.1)
        }

        it("accepts boundary values") {
            GeoCoordinate.parse("-90.0,-180.0") shouldBe GeoCoordinate(-90.0, -180.0)
            GeoCoordinate.parse("90.0,180.0") shouldBe GeoCoordinate(90.0, 180.0)
        }

        it("rejects out-of-range latitude") {
            GeoCoordinate.parse("90.1,0.0").shouldBeNull()
            GeoCoordinate.parse("-90.1,0.0").shouldBeNull()
        }

        it("rejects out-of-range longitude") {
            GeoCoordinate.parse("0.0,180.1").shouldBeNull()
            GeoCoordinate.parse("0.0,-180.1").shouldBeNull()
        }

        it("rejects non-numeric parts") {
            GeoCoordinate.parse("a,b").shouldBeNull()
            GeoCoordinate.parse("44.0,b").shouldBeNull()
        }

        it("rejects single-part and empty values") {
            GeoCoordinate.parse("44.0").shouldBeNull()
            GeoCoordinate.parse("").shouldBeNull()
        }
    }

    describe("altitude") {
        it("defaults to null for 2-part property values") {
            GeoCoordinate.parse("44.0,-123.0") shouldBe GeoCoordinate(44.0, -123.0)
        }

        it("parses the 3-part property form") {
            GeoCoordinate.parse("44.166604,-122.9738919,442") shouldBe
                GeoCoordinate(44.166604, -122.9738919, 442.0)
        }

        it("accepts negative altitude") {
            GeoCoordinate.parse("46.0,-123.0,-10.5") shouldBe
                GeoCoordinate(46.0, -123.0, -10.5)
        }

        it("rejects a garbage altitude component") {
            GeoCoordinate.parse("44.0,-123.0,bogus").shouldBeNull()
        }

        it("rejects NaN and Infinity altitude components") {
            GeoCoordinate.parse("44.0,-123.0,NaN").shouldBeNull()
            GeoCoordinate.parse("44.0,-123.0,Infinity").shouldBeNull()
        }

        it("rejects 4-part values") {
            GeoCoordinate.parse("44.0,-123.0,442.0,extra").shouldBeNull()
        }
    }

    describe("toPropertyValue") {
        it("round-trips with parse") {
            val coord = GeoCoordinate(44.1422919, -122.9749531)
            GeoCoordinate.parse(coord.toPropertyValue()) shouldBe coord
        }

        it("round-trips with altitude through the 3-part form") {
            val coord = GeoCoordinate(44.166604, -122.9738919, 442.0)
            GeoCoordinate.parse(coord.toPropertyValue()) shouldBe coord
        }
    }
})

OsmAnd facts driving the model

: GpxUtilities.kt, GpxFile.kt, FavouritesHelper.java, FavouritePoint.java, FavoriteGroup.java).

Extension keys must be colon-free

OsmAnd re-prefixes every key with osmand: and replaces : with _- (GpxFile.XML_COLON), so keys we emit must never contain colons. Ours: osmand:arcology_id, osmand:arcology_org, osmand:arcology_outline. Reader side: keys from osmand.net / garmin.com namespaces and known prefixes (osmand/gpxtpx/gpxx) keep the bare local tag name; anything else stored as prefix:name. Parse by plain key name after prefix-strip, never by namespace URI.

Extension values are TEXT-only

readExtensionsText nulls its buffer on any nested START_TAG (nested XML silently dropped) and drops blank values. No structure in extensions, ever — structure lives in org file-properties.

Favorites behavior facts

  • addFavourite backfills altitude (initAltitude) when ele is NaN/0.

  • Favorites dedupe is name-only, per group (containsPointByName) — identity keyed on group+name matches OsmAnd's own keying.

  • Re-import is NOT an upsert: FavoritesImportTask.mergeFavorites deletes list-equal existing points then adds incoming; checkDuplicateNames renames duplicates name (1), name (2).... Drives the determinism requirement on export.

Data representation: one heading per wpt

GEO_COORDS + OSMAND_* properties + :ID: per the ID policy + body per the body policy + a file link to the source gpx kept verbatim as an attachment.

Property mapping

  • icon color background wpt extensions → OSMAND_ICON OSMAND_COLOR OSMAND_BACKGROUND

  • <type> (the OsmAnd group) → ARCOLOGY_GROUP property holding the raw group name, plus a slugified org tag (property is the source of truth, tag is the queryable axis the map screen filters on)

  • Root points_groups → file-properties on the import file; rehydrated into the root extensions block on export

  • Unknown keys → one OSMAND_EXTENSIONS property holding escaped key=value pairs, colon-free with osmand: prefix stripped at capture, rehydrated verbatim on export. TEXT-only in, TEXT-only out.

  • ele → GeoCoordinate optional altitude; <time> → OSMAND_TIME property; cmt kept as a property (separate OsmAnd field, round-trips); <link> first-class (→ a link in the body / first published-attachment URL), not the unknowns bucket

Tracks and routes? (OPEN)

geo_coords only covers waypoints. Tracks/routes stay as plain .gpx sidecar attachments on a heading (=ATTACH=/AttachmentResolver infra covers publishing them); parsing a trk into headings is a later importer problem.

Advanced GPX features not being used? (OPEN)

Waypoint customization etc. — some in file-properties, some sidecar XML; do sidecars need processing in an indexer plugin? Defer.

Identity policy — deterministic node IDs

  • Round-tripped point: osmand:arcology_id present → its value is the org :ID:, updated in place.

  • Native favorite, first contact: uuid5-style deterministic UUID over group + wpt name, built WaypointId.kt on the existing HashUtils expect/actual (SHA-256, DECIDED 2026-10-08 — pure commonMain, no new platform code; JVM nameUUIDFromBytes rejected).

  • Determinism buys: idempotent re-import of the same file; no duplicate node when a pin is dragged (coordinates are the mutable part); pre-resolved marker→heading anchors for the web surface.

  • Pin the construction in commonMain so app and CLI agree.

WaypointId.kt

The salted, versioned namespace (DECIDED 2026-10-09) keeps these IDs from colliding with any other deterministic-ID scheme in the codebase and lets the formula be superseded (osmand-wpt-v1 → v2) without reinterpreting old IDs. Group enters with its literal value — the empty default group contributes the empty string, which is fine because name carries uniqueness. Formatting follows UUIDv5 shapes (version nibble 5, RFC-4122 variant bits) so downstream consumers see a plain-looking UUID string; the digest is SHA-256 rather than v5's canonical SHA-1, hence "uuid5-style". Test vectors are locked in the test block below — the formula is frozen once emitted.

kotlin#+name: waypoint-id-class:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/gis/WaypointId.kt
package computer.whatthefuck.arcology.gis

import computer.whatthefuck.arcology.utils.HashUtils

/**
 * Deterministic org node IDs for OsmAnd waypoints.
 *
 * First-contact favorites get a uuid5-style ID minted from the OsmAnd group
 * name and the waypoint name: identity matches OsmAnd's own favorites keying
 * (group + name, per containsPointByName), so a re-imported or dragged pin
 * keeps its org node. If the point already carries osmand:arcology_id, that
 * value wins — see GpxOrgMapping.orgIdFor.
 *
 * Changing the namespace salt orphans every ID minted under the old salt;
 * the formula is pinned by test vectors in WaypointIdTest.
 */
object WaypointId {
    private const val ID_NAMESPACE = "osmand-wpt-v1"

    fun forWaypoint(group: String, name: String): String {
        val digest = HashUtils.sha256("$ID_NAMESPACE:$group/$name")
        return fromSha256Hex(digest)
    }

    /**
     * uuid5-style formatting: take the first 16 bytes of the SHA-256 hex
     * digest, stamp the version nibble (5) and RFC-4122 variant bits, and
     * group into 8-4-4-4-12.
     */
    private fun fromSha256Hex(hex: String): String {
        require(hex.length >= 32) { "sha256 digest too short" }
        val chars = hex.take(32).toCharArray()
        val hexDigit = fun(c: Char): Int = if (c.isDigit()) c - '0' else (c.lowercaseChar() - 'a') + 10
        // Version nibble: 13th hex digit (byte 6, high nibble)
        chars[12] = '5'
        // Variant bits: byte 8, high nibble forced into 8..b
        chars[16] = "89ab"[hexDigit(chars[16]) and 0x3]
        return buildString(36) {
            append(chars, 0, 8)
            append('-')
            append(chars, 8, 4)
            append('-')
            append(chars, 12, 4)
            append('-')
            append(chars, 16, 4)
            append('-')
            append(chars, 20, 12)
        }
    }
}

WaypointIdTest

Vectors computed from the frozen formula (SHA-256 over osmand-wpt-v1:<group>/<name>, version-5 stamp) and locked here so any accidental change breaks loudly instead of silently re-minting IDs.

kotlin#+name: waypoint-id-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/gis/WaypointIdTest.kt
package computer.whatthefuck.arcology.gis

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldMatch

class WaypointIdTest : DescribeSpec({

    describe("forWaypoint") {
        it("pins test vectors") {
            WaypointId.forWaypoint("Coburg Hills and Cascades", "Coburg Ridge") shouldBe
                "1756f84b-8cfa-5546-b4f5-ad39ec98ba3f"
            WaypointId.forWaypoint("", "clearing leading in to old growth") shouldBe
                "7ea59103-dc3c-535a-a573-97d9cd7ceb54"
            WaypointId.forWaypoint("Camp Bike Etc", "Siuslaw Falls") shouldBe
                "b87822db-defc-5e72-a1cc-a805f6fef1bb"
        }

        it("produces a hyphenated uuid string with version 5 and RFC-4122 variant") {
            val id = WaypointId.forWaypoint("some group", "some name")
            id shouldMatch "^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
        }

        it("is deterministic and input-sensitive") {
            val a1 = WaypointId.forWaypoint("group", "name")
            val a2 = WaypointId.forWaypoint("group", "name")
            val b = WaypointId.forWaypoint("group", "other name")
            val c = WaypointId.forWaypoint("other group", "name")
            val d = WaypointId.forWaypoint("group/", "name")
            a1 shouldBe a2
            listOf(b, c, d).forEach { it shouldNotBe a1 }
        }

        it("distinguishes the salt from a bare group/name digest") {
            // Without the salt namespace, "osmand-wpt-v1:group/name" as group would collide
            val withSalt = WaypointId.forWaypoint("group", "name")
            val noSalt = WaypointId.forWaypoint("osmand-wpt-v1:group", "name")
            withSalt shouldNotBe noSalt
        }
    }
})

Group placement convention

One org file per imported gpx in the imports directory (alongside the journals convention in AppPreferences); OsmAnd groups map onto the org heading outline path inside that file — group =="Siuslaw/campsites"= → nested = Siuslaw= → =* campsites= headings around the point. Full convention (DECIDED 2026-10-09, superseding the original file-per-group plan): maps/import-export.org.

The "" default group and the personal spelling

Observed in the fixture: =<group name="" pinned="true">= (OsmAnd's default bucket) and a group literally named personal. Placement mapping: the empty default group places points at file top level (re-export emits no <type> and the point returns to the default bucket); the literal personal group becomes a * personal heading (it re-exports as group personal). Each of OsmAnd's two spellings round-trips to itself. The empty default group still yields group string "" for ID minting (WaypointId.forWaypoint("", name)) and no ARCOLOGY_GROUP property / no tag.

GPX data model

GPX 1.1 root with =version=/=creator= attributes, optional <metadata>, repeated <wpt> children (phase-1 parser home; the phase-3 writer emits this same model). Root-level <extensions> split two ways — osmand:points_groups parses structurally into pointGroups (it's attribute-bearing nesting, exempt from the TEXT-only rule), every other root child goes into the rootExtensions text map. Both are @Transient for serialization: they rehydrate into the root extensions block by hand at export (phase 3), the plain-field shape is parse-time only.

@Serializable annotations are wired now so the exporter can use xmlutil encode later, but the extension map needs a custom serializer for =osmand:=-namespaced child emission — phase 3's problem, noted at the extensions field. The importer never serializes these; it reads through the parser.

kotlin#+name: gpx-models-class:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxModels.kt
package computer.whatthefuck.arcology.gis

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName

/**
 * GPX 1.1 data model for the OsmAnd favorites bridge.
 * See https://www.topografix.com/gpx.asp and
 * https://osmand.net/docs/technical/osmand-file-formats/osmand-gpx.
 *
 * xmlutil serializes simple-type properties as *attributes* by default;
 * child elements carry @XmlElement(true). There is no @XmlAttribute
 * annotation in this xmlutil version — attribute naming uses @XmlSerialName.
 */

const val GPX_NAMESPACE = "http://www.topografix.com/GPX/1/1"
const val OSMAND_GPX_NAMESPACE =
    "https://osmand.net/docs/technical/osmand-file-formats/osmand-gpx"

/** Namespace fragments that mark a known extension namespace. */
internal const val KNOWN_NS_OSMAND = "osmand.net"
internal const val KNOWN_NS_GARMIN = "garmin.com"

/** OsmAnd prefixes whose keys are stored bare (colon-free) on import. */
val GPX_KNOWN_EXTENSION_PREFIXES = setOf("osmand", "gpxtpx", "gpxx")

/**
 * arcology_* extension keys, stored bare on import; the osmand: prefix is
 * added at emission time. Keys we emit must never contain colons — OsmAnd
 * re-prefixes every key with osmand: and replaces any ":" with "_" (XML_COLON).
 */
const val EXT_ARCOLOGY_ID = "arcology_id"
const val EXT_ARCOLOGY_ORG = "arcology_org"
const val EXT_ARCOLOGY_OUTLINE = "arcology_outline"

const val GPX_VERSION = "1.1"
const val GPX_CREATOR = "arcology2go"

/**
 * Per-group favorite appearance, parsed from the root osmand:points_groups
 * block. Values ride through verbatim: unknown future attributes round-trip,
 * and absent-vs-false stays distinguishable.
 */
data class GpxPointGroup(
    val name: String,
    val color: String? = null,
    val icon: String? = null,
    val background: String? = null,
    val pinned: String? = null,
    val hidden: String? = null
)

@Serializable
@XmlSerialName("gpx", GPX_NAMESPACE, "")
data class Gpx(
    @XmlSerialName("version")
    val version: String = GPX_VERSION,
    @XmlSerialName("creator")
    val creator: String = GPX_CREATOR,
    @XmlElement(true)
    val metadata: GpxMetadata? = null,
    @XmlSerialName("wpt", GPX_NAMESPACE, "")
    val waypoints: List<GpxWaypoint> = emptyList(),
    /** Root extension children other than osmand:points_groups (TEXT-only). */
    @Transient
    val rootExtensions: Map<String, String> = emptyMap(),
    /** Structural osmand:points_groups entries from the root extensions block. */
    @Transient
    val pointGroups: List<GpxPointGroup> = emptyList()
) {
    /** Group name for identity: the empty default group (the =personal= bucket) yields "". */
    fun groupNames(): Set<String> = pointGroups.map { it.name }.toSet()
}

@Serializable
@XmlSerialName("metadata", GPX_NAMESPACE, "")
data class GpxMetadata(
    @XmlElement(true)
    val name: String? = null,
    @XmlSerialName("desc", GPX_NAMESPACE, "")
    @XmlElement(true)
    val description: String? = null,
    @XmlSerialName("link", GPX_NAMESPACE, "")
    val links: List<GpxLink> = emptyList(),
    /**
     * Metadata-level extension children — the channel that carries
     * osmand:arcology_key / osmand:arcology_feed. TEXT-only values, keys
     * stored bare after the known-prefix strip.
     */
    @Transient
    val extensions: Map<String, String> = emptyMap()
)

@Serializable
@XmlSerialName("link", GPX_NAMESPACE, "")
data class GpxLink(
    @XmlSerialName("href")
    val href: String,
    @XmlElement(true)
    val text: String? = null
)

@Serializable
@XmlSerialName("wpt", GPX_NAMESPACE, "")
data class GpxWaypoint(
    @XmlSerialName("lat")
    val latitude: Double,
    @XmlSerialName("lon")
    val longitude: Double,
    /** The <ele> child in meters; null when absent. */
    @XmlElement(true)
    val ele: Double? = null,
    /** Raw ISO-8601 <time> text. Carried verbatim — it participates in OsmAnd identity (FavouritePoint.equals). */
    @XmlElement(true)
    val time: String? = null,
    @XmlElement(true)
    val name: String? = null,
    @XmlElement(true)
    val desc: String? = null,
    /** OsmAnd keeps <cmt> as its own field; carried as a property so it round-trips. */
    @XmlElement(true)
    val cmt: String? = null,
    /** The OsmAnd group name. Null/empty is the default group (the =personal= bucket). */
    @XmlElement(true)
    val type: String? = null,
    @XmlSerialName("link", GPX_NAMESPACE, "")
    val links: List<GpxLink> = emptyList(),
    /** Wpt extension children — keys bare after the known-prefix strip, values TEXT-only. */
    @Transient
    val extensions: Map<String, String> = emptyMap(),
    @Transient
    val warnings: List<String> = emptyList()
)

GpxWaypoint warnings

Per-wpt parse notes — malformed coordinates skip the wpt but record why, feeding the phase-2 review screen so nothing drops silently. Carried on the waypoint (not the result) because diffs want them adjacent to the diffed item.

GpxOrgMapping — pure property mappers

All OsmAnd→org mapping as pure functions over the model. The import service (phase 2) applies toOrgProperties through OrgDocumentEditor; the exporter (phase 3) feeds the inverse side. No I/O anywhere.

kotlin#+name: gpx-org-mapping:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxOrgMapping.kt
package computer.whatthefuck.arcology.gis

import computer.whatthefuck.arcology.domain.GeoCoordinate

/**
 * Pure mappers from OsmAnd GPX waypoints to org-mode heading properties and
 * body pieces — the org side of the OsmAnd bridge, shared by import (phase 2)
 * and export (phase 3).
 */
object GpxOrgMapping {

    const val PROP_OSMAND_ICON = "OSMAND_ICON"
    const val PROP_OSMAND_COLOR = "OSMAND_COLOR"
    const val PROP_OSMAND_BACKGROUND = "OSMAND_BACKGROUND"
    const val PROP_OSMAND_TIME = "OSMAND_TIME"
    const val PROP_OSMAND_CMT = "OSMAND_CMT"
    const val PROP_ARCOLOGY_GROUP = "ARCOLOGY_GROUP"
    const val PROP_OSMAND_EXTENSIONS = "OSMAND_EXTENSIONS"
    const val PROP_ARCOLOGY_POINTS_GROUPS = "ARCOLOGY_POINTS_GROUPS"

    /**
     * Dedicated homes for specific extension keys; everything else in the
     * extensions map lands in the OSMAND_EXTENSIONS unknowns bucket (keys as
     * observed after the known-prefix strip — e.g. "amenity_origin", the
     * mangled form OsmAnd 5.x writes).
     */
    private val routedKeys = setOf("icon", "color", "background") + arcologyKeys()

    fun arcologyKeys(): Set<String> = setOf(EXT_ARCOLOGY_ID, EXT_ARCOLOGY_ORG, EXT_ARCOLOGY_OUTLINE)

    /**
     * Heading properties for one waypoint:
     * GEO_COORDS (3-part when <ele> present), ARCOLOGY_GROUP (raw group name;
     * absent for the empty default group — the personal bucket has no group
     * tag), OSMAND_TIME, OSMAND_CMT, the icon/color/background trio, and the
     * escaped OSMAND_EXTENSIONS bucket for everything else.
     */
    fun toOrgProperties(wpt: GpxWaypoint): Map<String, String> {
        val props = LinkedHashMap<String, String>()

        val coords = GeoCoordinate(wpt.latitude, wpt.longitude, wpt.ele)
        props[GeoCoordinate.PROPERTY_KEY] = coords.toPropertyValue()

        wpt.type?.takeIf { it.isNotEmpty() }?.let { props[PROP_ARCOLOGY_GROUP] = it }
        wpt.time?.let { props[PROP_OSMAND_TIME] = it }
        wpt.cmt?.let { props[PROP_OSMAND_CMT] = it }

        wpt.extensions["icon"]?.let { props[PROP_OSMAND_ICON] = it }
        wpt.extensions["color"]?.let { props[PROP_OSMAND_COLOR] = it }
        wpt.extensions["background"]?.let { props[PROP_OSMAND_BACKGROUND] = it }

        val unknowns = wpt.extensions.filterKeys { it !in routedKeys }
        if (unknowns.isNotEmpty()) props[PROP_OSMAND_EXTENSIONS] = encodeExtensions(unknowns)

        return props
    }

    /**
     * The org :ID: for a waypoint per the identity policy:
     * osmand:arcology_id present → its value verbatim (updated in place);
     * first contact → the deterministic group+name UUID.
     */
    fun orgIdFor(wpt: GpxWaypoint): String {
        wpt.extensions[EXT_ARCOLOGY_ID]?.let { return it }
        return WaypointId.forWaypoint(groupFor(wpt), wpt.name.orEmpty())
    }

    /** The OsmAnd group for identity/file placement; the empty default group yields "". */
    fun groupFor(wpt: GpxWaypoint): String = wpt.type ?: ""

    /**
     * Org tag for the group axis the map screen filters on (ARCOLOGY_GROUP holds
     * the raw name; the tag is the queryable projection). Null for the empty
     * default group — the personal bucket carries no tag.
     */
    fun orgTagFor(group: String?): String? =
        group?.takeIf { it.isNotBlank() }?.let { slugify(it) }

    /**
     * Slugify a group name into an org-tag-safe token. Org tags allow only
     * [[:alnum:]_@#%:] — hyphens are NOT valid org tag characters (Emacs
     * rejects them), so every other char — including hyphens themselves —
     * becomes "_", runs collapse, edge underscores trim. Case preserved.
     */
    fun slugify(text: String): String =
        buildString {
            for (char in text) {
                when (char) {
                    in 'a'..'z', in 'A'..'Z', in '0'..'9', '_', '@', '#', '%' -> append(char)
                    else -> append('_')
                }
            }
        }
            .replace(Regex("_{2,}"), "_")
            .trim('_')

    /**
     * Tag-span pattern shared by the import and export services: the trailing
     * " :tag1:tag2:" of a heading line. Charset mirrors [slugify]'s org tag
     * rules (no hyphens).
     */
    internal val TAG_SPAN = Regex("\\s+(:[\\w@#%:]+:)\\s*$")

    /**
     * Strip the trailing tag span from a stored heading title. The parser's
     * [extractTitle] keeps tags in the title column, so DB titles look like
     * "Point :tag:" — the OsmAnd wpt <name> (and with it the group+name
     * identity) needs the clean text.
     */
    fun stripTagSpan(title: String): String =
        TAG_SPAN.find(title)?.let { title.substring(0, it.range.first).trimEnd() } ?: title

    /**
     * Heading start position carrying this :ID: value — the importer's
     * position lookup, shared with the exporter (both scan raw file text).
     */
    fun findHeadingPositionById(content: String, nodeId: String): Int? {
        val idMatch = Regex(":ID:\\s*${Regex.escape(nodeId)}\\s*$", RegexOption.MULTILINE).find(content)
            ?: return null
        val beforeId = content.substring(0, idMatch.range.first)
        return Regex("^\\*+ .*$", RegexOption.MULTILINE)
            .findAll(beforeId)
            .lastOrNull()
            ?.range
            ?.first
    }

    /** Number of leading stars on the heading line at [headingPosition]. */
    fun headingLevelAt(content: String, headingPosition: Int): Int {
        val lineEnd = content.indexOf('\n', headingPosition).let { if (it == -1) content.length else it }
        return content.substring(headingPosition, lineEnd).takeWhile { it == '*' }.length
    }

    /**
     * File-level properties for a parsed gpx: the root points_groups block,
     * encoded into a single reversible property. Other root extension keys
     * are ignored here (points_groups is the only structured root block).
     */
    fun filePropertiesFor(gpx: Gpx): Map<String, String> =
        if (gpx.pointGroups.isEmpty()) {
            emptyMap()
        } else {
            mapOf(PROP_ARCOLOGY_POINTS_GROUPS to encodePointGroups(gpx.pointGroups))
        }

    fun encodePointGroups(groups: List<GpxPointGroup>): String =
        groups.joinToString("|") { group ->
            buildList {
                add("name=${escapePart(group.name)}")
                group.color?.let { add("color=${escapePart(it)}") }
                group.icon?.let { add("icon=${escapePart(it)}") }
                group.background?.let { add("background=${escapePart(it)}") }
                group.pinned?.let { add("pinned=${escapePart(it)}") }
                group.hidden?.let { add("hidden=${escapePart(it)}") }
            }.joinToString(";")
        }

    fun decodePointGroups(encoded: String): List<GpxPointGroup> =
        splitUnescaped(encoded, '|').filter { it.isNotBlank() }.mapNotNull { groupSpec ->
            val fields = splitUnescaped(groupSpec, ';')
            val pairs = fields
                .map { it.trim() }
                .filter { it.isNotEmpty() }
                .map { field ->
                    val pieces = splitUnescaped(field, '=')
                    val key = unescapePart(pieces.firstOrNull().orEmpty())
                    val value = if (pieces.size >= 2) pieces.drop(1).joinToString("=") else ""
                    key to unescapePart(value)
                }
            val name = pairs.firstOrNull { it.first == "name" }?.second ?: return@mapNotNull null
            GpxPointGroup(
                name = name,
                color = pairs.firstOrNull { it.first == "color" }?.second,
                icon = pairs.firstOrNull { it.first == "icon" }?.second,
                background = pairs.firstOrNull { it.first == "background" }?.second,
                pinned = pairs.firstOrNull { it.first == "pinned" }?.second,
                hidden = pairs.firstOrNull { it.first == "hidden" }?.second
            )
        }

    /**
     * The OSMAND_EXTENSIONS property: one line of "key=value" pairs joined by
     * "; ", backslash-escaped so any payload is reversible —
     * \\ to \\, ";" to \c, "=" to \e, newline to \n (and \r, \t). Unknown
     * escapes and a dangling trailing backslash decode verbatim-forward.
     */
    fun encodeExtensions(extensions: Map<String, String>): String =
        extensions.entries.joinToString("; ") { (key, value) ->
            "${escapePart(key)}=${escapePart(value)}"
        }

    fun decodeExtensions(encoded: String): Map<String, String> {
        val result = LinkedHashMap<String, String>()
        splitUnescaped(encoded, ';').forEach { segment ->
            val pair = segment.trim()
            if (pair.isEmpty()) return@forEach
            val pieces = splitUnescaped(pair, '=')
            val key = unescapePart(pieces.firstOrNull().orEmpty()).trim()
            if (key.isEmpty()) return@forEach
            val value = if (pieces.size >= 2) unescapePart(pieces.drop(1).joinToString("=")) else ""
            result[key] = value
        }
        return result
    }

    private fun escapePart(value: String): String = buildString(value.length) {
        for (char in value) {
            when (char) {
                '\\' -> append("\\\\")
                ';' -> append("\\c")
                '=' -> append("\\e")
                '\n' -> append("\\n")
                '\r' -> append("\\r")
                '\t' -> append("\\t")
                else -> append(char)
            }
        }
    }

    private fun unescapePart(value: String): String {
        if ('\\' !in value) return value
        return buildString(value.length) {
            var i = 0
            while (i < value.length) {
                val char = value[i]
                if (char != '\\') {
                    append(char)
                    i++
                    continue
                }
                i++
                if (i >= value.length) {
                    append('\\')
                    break
                }
                when (val escape = value[i]) {
                    '\\' -> append('\\')
                    'c' -> append(';')
                    'e' -> append('=')
                    'n' -> append('\n')
                    'r' -> append('\r')
                    't' -> append('\t')
                    else -> {
                        append('\\')
                        append(escape)
                    }
                }
                i++
            }
        }
    }

    /** Split on the separator, treating a two-char backslash escape as one unit. */
    private fun splitUnescaped(value: String, separator: Char): List<String> {
        val segments = mutableListOf<String>()
        val current = StringBuilder()
        var i = 0
        while (i < value.length) {
            val char = value[i]
            when {
                char == '\\' && i + 1 < value.length -> {
                    current.append(char)
                    current.append(value[i + 1])
                    i += 2
                }
                char == separator -> {
                    segments.add(current.toString())
                    current.clear()
                    i++
                }
                else -> {
                    current.append(char)
                    i++
                }
            }
        }
        segments.add(current.toString())
        return segments
    }

    /**
     * Body policy v1, pure side: <desc> passes through verbatim and <link>
     * hrefs become plain org links ([] syntax — never ATTACH; the review
     * screen lists linked points). Native favorites carry HTML descs; the
     * HTML-to-plain-text pass is the import service's (phase 2). With neither
     * desc nor links the caller composes minimalBody.
     */
    fun linkLinesFor(wpt: GpxWaypoint): List<String> =
        wpt.links.map { link ->
            val text = link.text?.takeIf { it.isNotBlank() } ?: link.href
            "[[${link.href}][${text}]]"
        }

    fun bodyTextFor(wpt: GpxWaypoint): String? {
        val lines = buildList {
            wpt.desc?.takeIf { it.isNotBlank() }?.let { add(it) }
            addAll(linkLinesFor(wpt))
        }
        return if (lines.isEmpty()) null else lines.joinToString("\n")
    }

    fun minimalBody(coord: GeoCoordinate): String = "Coordinates: ${coord.toPropertyValue()}"
}

GpxModelsTest — model & mapping tests

kotlin#+name: gpx-models-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/gis/GpxModelsTest.kt
package computer.whatthefuck.arcology.gis

import computer.whatthefuck.arcology.domain.GeoCoordinate
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe

class GpxModelsTest : DescribeSpec({

    describe("toOrgProperties") {
        it("maps the full known-key set") {
            val wpt = GpxWaypoint(
                latitude = 44.1422919,
                longitude = -122.9749531,
                ele = 442.0,
                time = "2024-12-30T00:13:16Z",
                name = "clearing leading in to old growth",
                cmt = "a comment",
                type = "Coburg Hills and Cascades",
                extensions = mapOf(
                    "icon" to "park",
                    "color" to "#EECC22",
                    "background" to "circle",
                    "amenity_origin" to "some origin",
                    EXT_ARCOLOGY_ID to "00000000-0000-0000-0000-000000000000"
                )
            )

            val props = GpxOrgMapping.toOrgProperties(wpt)

            props[GeoCoordinate.PROPERTY_KEY] shouldBe "44.1422919,-122.9749531,442.0"
            props[GpxOrgMapping.PROP_ARCOLOGY_GROUP] shouldBe "Coburg Hills and Cascades"
            props[GpxOrgMapping.PROP_OSMAND_TIME] shouldBe "2024-12-30T00:13:16Z"
            props[GpxOrgMapping.PROP_OSMAND_CMT] shouldBe "a comment"
            props[GpxOrgMapping.PROP_OSMAND_ICON] shouldBe "park"
            props[GpxOrgMapping.PROP_OSMAND_COLOR] shouldBe "#EECC22"
            props[GpxOrgMapping.PROP_OSMAND_BACKGROUND] shouldBe "circle"
        }

        it("keeps the empty default group out of properties") {
            val props = GpxOrgMapping.toOrgProperties(
                GpxWaypoint(latitude = 44.0, longitude = -123.0)
            )
            props[GpxOrgMapping.PROP_ARCOLOGY_GROUP].shouldBeNull()
        }

        it("lands unknown keys in OSMAND_EXTENSIONS and skips routed keys") {
            val wpt = GpxWaypoint(
                latitude = 44.0,
                longitude = -123.0,
                extensions = mapOf(
                    "icon" to "park",
                    EXT_ARCOLOGY_ORG to "body text",
                    "amenity_type" to "restaurant"
                )
            )

            val props = GpxOrgMapping.toOrgProperties(wpt)
            val unknowns = checkNotNull(props[GpxOrgMapping.PROP_OSMAND_EXTENSIONS])
            // icon and arcology_* have dedicated homes; only amenity_type lands in the bucket
            GpxOrgMapping.decodeExtensions(unknowns) shouldBe mapOf(
                "amenity_type" to "restaurant"
            )
        }

        it("emits a 2-part coordinate when ele is absent") {
            val props = GpxOrgMapping.toOrgProperties(
                GpxWaypoint(latitude = 44.0, longitude = -123.0)
            )
            props[GeoCoordinate.PROPERTY_KEY] shouldBe "44.0,-123.0"
        }
    }

    describe("orgIdFor") {
        it("prefers an existing arcology_id") {
            val wpt = GpxWaypoint(
                latitude = 44.0,
                longitude = -123.0,
                name = "some point",
                type = "group",
                extensions = mapOf(EXT_ARCOLOGY_ID to "already-minted-id")
            )
            GpxOrgMapping.orgIdFor(wpt) shouldBe "already-minted-id"
        }

        it("mints a deterministic ID from group+name on first contact") {
            val wpt = GpxWaypoint(
                latitude = 44.0,
                longitude = -123.0,
                name = "Coburg Ridge",
                type = "Coburg Hills and Cascades"
            )
            GpxOrgMapping.orgIdFor(wpt) shouldBe
                WaypointId.forWaypoint("Coburg Hills and Cascades", "Coburg Ridge")
        }

        it("mints from the empty default group") {
            val wpt = GpxWaypoint(
                latitude = 44.0,
                longitude = -123.0,
                name = "personal bucket point"
            )
            GpxOrgMapping.orgIdFor(wpt) shouldBe
                WaypointId.forWaypoint("", "personal bucket point")
        }
    }

    describe("extensions escaping") {
        it("escapes the structural separators exactly once") {
            GpxOrgMapping.encodeExtensions(mapOf("a b" to "c=d")) shouldBe "a b=c\\ed"
            GpxOrgMapping.encodeExtensions(mapOf("k" to "v;x")) shouldBe "k=v\\cx"
        }

        it("round-trips payloads with separators and control chars") {
            val ext = mapOf(
                "amenity_type" to "restaurant; fancy=v\"ery\\odd",
                "osm_tag_ele" to "line\nbreak\rand\ttab"
            )
            val encoded = GpxOrgMapping.encodeExtensions(ext)
            encoded shouldBe
                "amenity_type=restaurant\\c fancy\\ev\"ery\\\\odd; osm_tag_ele=line\\nbreak\\rand\\ttab"
            GpxOrgMapping.decodeExtensions(encoded) shouldBe ext
        }

        it("keeps unknown escapes and dangling backslashes verbatim") {
            GpxOrgMapping.decodeExtensions("key=we\\ird\\") shouldBe mapOf("key" to "we\\ird\\")
        }

        it("drops malformed empty pairs") {
            GpxOrgMapping.decodeExtensions("key=value; ; ") shouldBe mapOf("key" to "value")
        }
    }

    describe("pointGroups") {
        it("round-trips group appearance") {
            val groups = listOf(
                GpxPointGroup(
                    name = "Coburg Hills and Cascades",
                    color = "#EECC22",
                    icon = "special_star",
                    background = "circle"
                ),
                GpxPointGroup(name = "", pinned = "true"),
                GpxPointGroup(name = "EUG Downtown/South", hidden = "false")
            )

            val decoded = GpxOrgMapping.decodePointGroups(GpxOrgMapping.encodePointGroups(groups))
            decoded shouldBe groups
        }

        it("encodes into the single ARCOLOGY_POINTS_GROUPS property") {
            val gpx = Gpx(pointGroups = listOf(GpxPointGroup(name = "g")))
            GpxOrgMapping.filePropertiesFor(gpx) shouldBe
                mapOf(GpxOrgMapping.PROP_ARCOLOGY_POINTS_GROUPS to "name=g")
            GpxOrgMapping.filePropertiesFor(Gpx()) shouldBe emptyMap()
        }
    }

    describe("slugify") {
        it("slugs group names into org tags, case preserved") {
            // Org tag charset is [[:alnum:]_@#%:] — no hyphens; runs collapse to underscores
            GpxOrgMapping.orgTagFor("Coburg Hills and Cascades") shouldBe
                "Coburg_Hills_and_Cascades"
            GpxOrgMapping.orgTagFor("EUG Downtown/South") shouldBe "EUG_Downtown_South"
        }

        it("collapses runs and trims edge underscores") {
            GpxOrgMapping.slugify("  --Spaced   Out-- ") shouldBe "Spaced_Out"
        }

        it("is null without a group") {
            GpxOrgMapping.orgTagFor(null).shouldBeNull()
            GpxOrgMapping.orgTagFor("").shouldBeNull()
        }
    }

    describe("body helpers") {
        it("desc + links make the body") {
            val wpt = GpxWaypoint(
                latitude = 44.0,
                longitude = -123.0,
                desc = "thirty minutes from Springfield and maybe a neat view",
                links = listOf(GpxLink(href = "https://example.com/photo.jpg", text = "photo"))
            )
            GpxOrgMapping.bodyTextFor(wpt) shouldBe
                "thirty minutes from Springfield and maybe a neat view\n[[https://example.com/photo.jpg][photo]]"
        }

        it("falls back to href when link text is blank") {
            val wpt = GpxWaypoint(
                latitude = 44.0,
                longitude = -123.0,
                links = listOf(GpxLink(href = "https://example.com/"))
            )
            GpxOrgMapping.linkLinesFor(wpt) shouldBe listOf("[[https://example.com/][https://example.com/]]")
        }

        it("is null without desc or links") {
            GpxOrgMapping.bodyTextFor(GpxWaypoint(latitude = 44.0, longitude = -123.0)).shouldBeNull()
        }

        it("minimal body carries the coordinate") {
            GpxOrgMapping.minimalBody(GeoCoordinate(44.0, -123.0)) shouldBe "Coordinates: 44.0,-123.0"
        }
    }
})

GpxParser

Streaming parse over xmlutil's XmlReader — @Serializable decode can't express the OsmAnd extension semantics (arbitrary key sets, TEXT-only values, nested-drop), so the parser walks events directly and mirrors OsmAnd's reader behaviors in =GpxUtilities=readExtensionsText= / readText: a nested start-tag inside an extension value drops the whole value, blank values drop, extension keys resolve by plain local tag name after the known-prefix strip (never by namespace URI). xmlutil fact learned in phase 2: XmlReader delivers entity references as separate ENTITY_REF events (xmlutil 0.91.3) whose text carries the decoded replacement — readValue must accumulate them alongside TEXT/CDSECT or every &amp;/&lt;/&gt; in a desc goes missing (GpxImportServiceTest caught this). osmand:points_groups is the one structured root child; it parses into pointGroups. Track/route elements are consumed and ignored (favorites import reads getPointsList only — importing a favorites file through a track importer is the trap, not the goal). Individual <wpt> with bad lat/lon skip with a warning — feeding the phase-2 review screen, not silently dropped.

kotlin#+name: gpx-parser-class:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxParser.kt
package computer.whatthefuck.arcology.gis

import nl.adaptivity.xmlutil.EventType
import nl.adaptivity.xmlutil.XmlReader
import nl.adaptivity.xmlutil.XmlStreaming

/**
 * Result of parsing a GPX document.
 */
data class GpxParseResult(
    /** Parsed model; null when the content isn't a GPX file or fails at root level. */
    val gpx: Gpx?,
    /** Human-readable diagnostics for skipped points and structure problems. */
    val warnings: List<String> = emptyList()
)

/**
 * Streaming parser for OsmAnd GPX 1.1 favorites files.
 *
 * Mirrors OsmAnd's own reader semantics where it matters:
 * - extensions are TEXT-only: a nested start-tag inside an extension value
 *   drops the whole value (readExtensionsText nulls its buffer), blank values
 *   drop after trimming;
 * - extension keys parse by plain local tag name after the known-prefix strip
 *   (osmand/gpxtpx/gpxx prefixes, osmand.net/garmin.com namespaces), never by
 *   namespace URI — OsmAnd mangles unknown-key colons into "_", so colon-free
 *   bare names are what a 5.x writer emits;
 * - osmand:points_groups parses structurally (per-group appearance attrs),
 *   exempt from the TEXT-only rule;
 * - trk/rte elements are consumed and ignored — favorites import reads wpts
 *   only.
 */
class GpxParser {

    fun parse(content: String): GpxParseResult {
        val warnings = mutableListOf<String>()
        val reader = try {
            // CharSequence-typed to pick the non-deprecated common overload;
            // the String-targeted newReader variant is deprecated in xmlutil 0.91
            val input: CharSequence = content
            XmlStreaming.newReader(input)
        } catch (e: Exception) {
            return GpxParseResult(gpx = null, warnings = listOf("XML parse failed: ${e.message}"))
        }
        return try {
            readDocument(reader, warnings)
        } catch (e: Exception) {
            GpxParseResult(gpx = null, warnings = warnings + "parse aborted: ${e.message}")
        }
    }

    private fun readDocument(reader: XmlReader, warnings: MutableList<String>): GpxParseResult {
        while (reader.hasNext()) {
            reader.next()
            if (reader.eventType == EventType.START_ELEMENT) {
                if (reader.name.localPart != "gpx") {
                    return GpxParseResult(
                        gpx = null,
                        warnings = warnings + "no <gpx> root element (found <${reader.name.localPart}>)"
                    )
                }
                return readRoot(reader, warnings)
            }
        }
        return GpxParseResult(gpx = null, warnings = warnings + "empty document")
    }

    private fun readRoot(reader: XmlReader, warnings: MutableList<String>): GpxParseResult {
        val version = reader.attr("version") ?: GPX_VERSION
        val creator = reader.attr("creator") ?: ""

        var metadata: GpxMetadata? = null
        val waypoints = mutableListOf<GpxWaypoint>()
        var routeGroups = emptyList<GpxPointGroup>()
        var rootExtensions = emptyMap<String, String>()
        var index = -1

        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> when (reader.localName) {
                    "metadata" -> metadata = readMetadata(reader)
                    "wpt" -> {
                        index++
                        readWaypoint(reader, index, warnings)?.let { waypoints.add(it) }
                    }
                    "extensions" -> {
                        val (textExts, groups) = readRootExtensions(reader)
                        rootExtensions = textExts
                        routeGroups = groups
                    }
                    else -> skipBody(reader)
                }
                EventType.END_ELEMENT -> break
                else -> {}
            }
        }

        val gpx = Gpx(
            version = version,
            creator = creator,
            metadata = metadata,
            waypoints = waypoints,
            rootExtensions = rootExtensions,
            pointGroups = routeGroups
        )
        return GpxParseResult(gpx = gpx, warnings = warnings)
    }

    private fun readMetadata(reader: XmlReader): GpxMetadata {
        var name: String? = null
        var description: String? = null
        val links = mutableListOf<GpxLink>()
        var extensions = emptyMap<String, String>()

        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> when (reader.localName) {
                    "name" -> name = readValue(reader)
                    "desc" -> description = readValue(reader)
                    "link" -> readLink(reader)?.let { links.add(it) }
                    "extensions" -> extensions = readExtensions(reader)
                    else -> skipBody(reader)
                }
                EventType.END_ELEMENT -> break
                else -> {}
            }
        }
        return GpxMetadata(
            name = name,
            description = description,
            links = links,
            extensions = extensions
        )
    }

    private fun readWaypoint(
        reader: XmlReader,
        index: Int,
        warnings: MutableList<String>
    ): GpxWaypoint? {
        val latitude = reader.attr("lat")?.trim()?.toDoubleOrNull()
        val longitude = reader.attr("lon")?.trim()?.toDoubleOrNull()
        if (latitude == null || longitude == null) {
            readPointBody(reader)
            warnings.add("wpt #$index: missing or invalid lat/lon — skipped")
            return null
        }

        var ele: Double? = null
        var time: String? = null
        var name: String? = null
        var desc: String? = null
        var cmt: String? = null
        var type: String? = null
        val links = mutableListOf<GpxLink>()
        var extensions = emptyMap<String, String>()

        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> when (reader.localName) {
                    "ele" -> ele = readValue(reader)?.trim()?.toDoubleOrNull()
                    "time" -> time = readValue(reader)
                    "name" -> name = readValue(reader)
                    "desc" -> desc = readValue(reader)
                    "cmt" -> cmt = readValue(reader)
                    "type" -> type = readValue(reader)
                    "link" -> readLink(reader)?.let { links.add(it) }
                    "extensions" -> extensions = readExtensions(reader)
                    else -> skipBody(reader)
                }
                EventType.END_ELEMENT -> break
                else -> {}
            }
        }

        return GpxWaypoint(
            latitude = latitude,
            longitude = longitude,
            ele = ele,
            time = time,
            name = name,
            desc = desc,
            cmt = cmt,
            type = type,
            links = links,
            extensions = extensions
        )
    }

    private fun readLink(reader: XmlReader): GpxLink? {
        val href = reader.attr("href")
        var text: String? = null

        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> when (reader.localName) {
                    "text" -> text = readValue(reader)
                    else -> skipBody(reader)
                }
                EventType.END_ELEMENT -> break
                else -> {}
            }
        }
        // A link without href carries nothing round-trippable; drop it.
        return href?.takeIf { it.isNotBlank() }?.let { GpxLink(href = it, text = text) }
    }

    private fun readExtensions(reader: XmlReader): Map<String, String> {
        val result = LinkedHashMap<String, String>()
        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> {
                    val key = extensionKey(reader)
                    val value = readValue(reader)
                    if (value != null) result[key] = value
                }
                EventType.END_ELEMENT -> break
                else -> {}
            }
        }
        return result
    }

    private fun readRootExtensions(reader: XmlReader): Pair<Map<String, String>, List<GpxPointGroup>> {
        val textExtensions = LinkedHashMap<String, String>()
        val groups = mutableListOf<GpxPointGroup>()

        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> when {
                    reader.localName == "points_groups" -> groups.addAll(readPointGroups(reader))
                    else -> {
                        val key = extensionKey(reader)
                        readValue(reader)?.let { textExtensions[key] = it }
                    }
                }
                EventType.END_ELEMENT -> break
                else -> {}
            }
        }
        return textExtensions to groups
    }

    private fun readPointGroups(reader: XmlReader): List<GpxPointGroup> {
        val groups = mutableListOf<GpxPointGroup>()
        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> groups.add(readPointGroup(reader))
                EventType.END_ELEMENT -> break
                else -> {}
            }
        }
        return groups
    }

    private fun readPointGroup(reader: XmlReader): GpxPointGroup {
        val group = GpxPointGroup(
            name = reader.attr("name").orEmpty(),
            color = reader.attr("color"),
            icon = reader.attr("icon"),
            background = reader.attr("background"),
            pinned = reader.attr("pinned"),
            hidden = reader.attr("hidden")
        )
        readPointBody(reader)
        return group
    }

    /**
     * Extension key resolution per the models policy: known prefixes
     * (osmand/gpxtpx/gpxx) and known namespaces (osmand.net/garmin.com)
     * strip to the bare local tag name; any other prefixed key stores as
     * "prefix:name"; un-namespaced keys are bare.
     */
    internal fun extensionKey(reader: XmlReader): String {
        val prefix = reader.prefix
        val namespace = reader.namespaceURI
        val known = prefix in GPX_KNOWN_EXTENSION_PREFIXES ||
            (namespace.isNotEmpty() && (KNOWN_NS_OSMAND in namespace || KNOWN_NS_GARMIN in namespace))
        return when {
            known -> reader.localName
            prefix.isNotEmpty() -> "$prefix:${reader.localName}"
            else -> reader.localName
        }
    }

    private fun XmlReader.attr(localName: String): String? {
        for (index in 0 until attributeCount) {
            val ns = getAttributeNamespace(index)
            if (ns.isNullOrEmpty() && getAttributeLocalName(index) == localName) {
                return getAttributeValue(index)
            }
        }
        return null
    }

    /**
     * Consume the remainder of the element the reader is positioned inside
     * (after its START_TAG), through its END_TAG.
     */
    private fun readPointBody(reader: XmlReader) {
        var depth = 0
        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> depth++
                EventType.END_ELEMENT -> if (depth == 0) return else depth--
                else -> {}
            }
        }
    }

    /** readPointBody for elements we don't care about; same traversal, different intent. */
    private fun skipBody(reader: XmlReader) = readPointBody(reader)

    /**
     * TEXT-only value reader mirroring OsmAnd's readExtensionsText: from the
     * position just inside a start tag, accumulate direct child text until
     * the matching END_TAG. A nested START_TAG anywhere inside marks the value
     * unreadable — the whole value is dropped (not truncated). Blank values
     * drop after trimming.
     */
    private fun readValue(reader: XmlReader): String? {
        val buffer = StringBuilder()
        var depth = 0
        var sawNestedTag = false

        while (reader.hasNext()) {
            when (reader.next()) {
                EventType.START_ELEMENT -> {
                    depth++
                    sawNestedTag = true
                }
                EventType.END_ELEMENT ->
                    if (depth == 0) {
                        return if (sawNestedTag) {
                            null
                        } else {
                            buffer.toString().trim().ifEmpty { null }
                        }
                    } else {
                        depth--
                    }
                EventType.TEXT, EventType.CDSECT, EventType.ENTITY_REF ->
                    if (depth == 0) buffer.append(reader.text)
                else -> {}
            }
        }
        return null
    }
}

}

#+end_src

GpxParserTest — fixture smoke + edge cases

kotlin#+name: gpx-parser-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/gis/GpxParserTest.kt
package computer.whatthefuck.arcology.gis

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldNotContain
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe

private val fixtureContent: String by lazy {
    val stream = GpxParserTest::class.java.getResourceAsStream("/gpx/favorites.gpx")
        ?: error("test resource gpx/favorites.gpx missing from the test classpath")
    stream.use { it.readBytes().decodeToString() }
}

class GpxParserTest : DescribeSpec({

    val parser = GpxParser()

    describe("favorites fixture") {
        it("parses the whole document") {
            val result = parser.parse(fixtureContent)

            result.warnings shouldBe emptyList()
            val gpx = checkNotNull(result.gpx)
            gpx.version shouldBe "1.1"
            gpx.creator shouldBe "OsmAnd~ 5.3.10"
            gpx.metadata?.name shouldBe "favorites"
            gpx.waypoints shouldHaveSize 110
        }

        it("splits root extensions: points_groups structurally, nothing left over") {
            val gpx = checkNotNull(parser.parse(fixtureContent).gpx)

            gpx.pointGroups shouldHaveSize 12
            gpx.pointGroups.map { it.name } shouldContain "Coburg Hills and Cascades"
            gpx.pointGroups.map { it.name } shouldContain ""
            gpx.pointGroups.map { it.name } shouldContain "personal"
            gpx.pointGroups.first { it.name.isBlank() }.pinned shouldBe "true"
            gpx.pointGroups.first { it.name.isBlank() }.icon shouldBe "special_star"
            gpx.pointGroups.first { it.name == "personal" }.icon shouldBe "special_house"
            gpx.rootExtensions shouldBe emptyMap()
        }

        it("captures the extension zoo as bare colon-free keys") {
            val gpx = checkNotNull(parser.parse(fixtureContent).gpx)

            val keys = gpx.waypoints.flatMap { it.extensions.keys }
            keys shouldContain "address"
            keys shouldContain "icon"
            keys shouldContain "hidden"
            keys shouldContain "color"
            keys shouldContain "background"
            keys shouldContain "amenity_origin"
            keys shouldContain "osm_tag_ele"
            keys shouldContain "collapsable_cuisine"
            keys shouldContain "website"
            keys shouldContain "phone"
            keys shouldContain "point_type"
            keys.forEach { it.contains(':') shouldBe false }
        }

        it("counts ele, desc, type, cmt, link as observed offline") {
            val gpx = checkNotNull(parser.parse(fixtureContent).gpx)

            gpx.waypoints.count { it.ele != null } shouldBe 58
            gpx.waypoints.count { it.desc != null } shouldBe 8
            gpx.waypoints.count { it.type != null } shouldBe 102
            gpx.waypoints.count { it.cmt != null } shouldBe 0
            gpx.waypoints.count { it.links.isNotEmpty() } shouldBe 0
        }

        it("keeps first-contact identity determinism and uniqueness") {
            val gpx = checkNotNull(parser.parse(fixtureContent).gpx)

            val ids = gpx.waypoints.map { GpxOrgMapping.orgIdFor(it) }
            ids.distinct().shouldHaveSize(110)
        }

        it("puts the duplicate-suffixed names in unique group+name space") {
            val gpx = checkNotNull(parser.parse(fixtureContent).gpx)

            val dupe = gpx.waypoints.first { it.name?.endsWith("(2966)") == true }
            dupe.name shouldBe "Bellingham Technical College (2966)"
            GpxOrgMapping.orgIdFor(dupe) shouldBe
                WaypointId.forWaypoint(GpxOrgMapping.groupFor(dupe), dupe.name.orEmpty())
        }
    }

    describe("edge cases") {
        it("drops extension values with nested tags instead of truncating") {
            val result = parser.parse(wrap(WPT_WITH_NESTED_EXT))

            val wpt = checkNotNull(result.gpx).waypoints.first()
            wpt.extensions.keys shouldNotContain "a"
        }

        it("drops blank extension values") {
            val result = parser.parse(wrap(WPT_WITH_BLANK_EXT))

            val wpt = checkNotNull(result.gpx).waypoints.first()
            wpt.extensions shouldBe emptyMap()
        }

        it("keeps known prefixes bare") {
            val result = parser.parse(wrap(WPT_WITH_OSMAND_EXT))

            val wpt = checkNotNull(result.gpx).waypoints.first()
            wpt.extensions shouldBe mapOf("icon" to "park")
        }

        it("stores unknown prefixed keys as prefix:name") {
            val result = parser.parse(
                wrap(WPT_WITH_FOO_EXT, " xmlns:foo=\"https://example.com/foo\"")
            )

            val wpt = checkNotNull(result.gpx).waypoints.first()
            wpt.extensions shouldBe mapOf("foo:thing" to "value")
        }

        it("stores un-namespaced unprefixed keys bare") {
            val result = parser.parse(wrap(WPT_WITH_PLAINKEY_EXT))

            val wpt = checkNotNull(result.gpx).waypoints.first()
            wpt.extensions shouldBe mapOf("plainkey" to "value")
        }

        it("missing or empty type lands in the empty default group") {
            val result = parser.parse(wrap(WPT_MISSING_TYPE))

            val wpt = checkNotNull(result.gpx).waypoints.first()
            GpxOrgMapping.groupFor(wpt) shouldBe ""
            GpxOrgMapping.orgTagFor(GpxOrgMapping.groupFor(wpt)).shouldBeNull()
        }

        it("passes links through") {
            val result = parser.parse(wrap(WPT_WITH_LINK))

            val wpt = checkNotNull(result.gpx).waypoints.first()
            wpt.links shouldBe listOf(GpxLink(href = "https://example.com/photo.jpg", text = "photo"))
            GpxOrgMapping.bodyTextFor(wpt) shouldBe "[[https://example.com/photo.jpg][photo]]"
        }

        it("skips malformed lat/lon wpts with a warning") {
            val result = parser.parse(wrap(TWO_WPTS_ONE_MALFORMED))

            val gpx = checkNotNull(result.gpx)
            gpx.waypoints shouldHaveSize 1
            result.warnings shouldHaveSize 1
            result.warnings.first() shouldBe "wpt #0: missing or invalid lat/lon — skipped"
        }

        it("returns null gpx for non-GPX content") {
            val result = parser.parse("<html><body>nope</body></html>")

            result.gpx.shouldBeNull()
            result.warnings.first() shouldBe "no <gpx> root element (found <html>)"
        }

        it("ignores trk and rte elements") {
            val result = parser.parse(wrap(TRK_AND_RTE))

            checkNotNull(result.gpx).waypoints shouldHaveSize 0
        }
    }
})

/** gpx root wrapper declaring the osmand namespace OsmAnd writes. */
private fun wrap(content: String, extraDecl: String = ""): String =
    "<gpx version=\"1.1\" creator=\"t\" xmlns:osmand=\"$OSMAND_GPX_NAMESPACE\"$extraDecl>$content</gpx>"

private val WPT_WITH_NESTED_EXT = """
<wpt lat="44.0" lon="-123.0"><name>nested</name>
  <extensions>
    <osmand:a>text<nested>gone</nested>more</osmand:a>
  </extensions>
</wpt>
""".trimIndent()

private val WPT_WITH_BLANK_EXT = """
<wpt lat="44.0" lon="-123.0"><name>blank</name>
  <extensions>
    <osmand:b>   </osmand:b>
  </extensions>
</wpt>
""".trimIndent()

private val WPT_WITH_OSMAND_EXT = """
<wpt lat="44.0" lon="-123.0"><name>known</name>
  <extensions>
    <osmand:icon>park</osmand:icon>
  </extensions>
</wpt>
""".trimIndent()

private val WPT_WITH_FOO_EXT = """
<wpt lat="44.0" lon="-123.0"><name>foo</name>
  <extensions>
    <foo:thing>value</foo:thing>
  </extensions>
</wpt>
""".trimIndent()

private val WPT_WITH_PLAINKEY_EXT = """
<wpt lat="44.0" lon="-123.0"><name>plain</name>
  <extensions>
    <plainkey>value</plainkey>
  </extensions>
</wpt>
""".trimIndent()

private val WPT_MISSING_TYPE = """
<wpt lat="44.0" lon="-123.0"><name>personal point</name></wpt>
""".trimIndent()

private val WPT_WITH_LINK = """
<wpt lat="44.0" lon="-123.0"><name>linked</name>
  <link href="https://example.com/photo.jpg"><text>photo</text></link>
</wpt>
""".trimIndent()

private val TWO_WPTS_ONE_MALFORMED = """
<wpt><name>no coords</name></wpt>
<wpt lat="45.0" lon="-124.0"><name>fine</name></wpt>
""".trimIndent()

private val TRK_AND_RTE = """
<trk><name>track</name><trkseg><trkpt lat="44.0" lon="-123.0"/></trkseg></trk>
<rte><rtept lat="44.0" lon="-123.0"/></rte>
""".trimIndent()

Fixture findings — scratch/favorites.gpx (2026-10-08)

59KB, 110 wpts, 10 named groups in <type> land + the empty default group, creator OsmAnd~ 5.3.10. points_groups carries 12 entries: the 10 named, the empty "" default (pinned, special_star), and a group literally named personal (special_house, with the Personal default — OsmAnd's own Personal-group spellings). Under the phase-2 placement convention both land in the same import file: "" points at file top level, personal points under a * personal heading (import-export.org).

  • No =arcology_= keys anywhere → every point is first contact; first import mints 110 deterministic IDs. Good smoke test. - Extension zoo beyond the known five: amenity_ (8 keys), osm_tag_ (20+, values encoded in key names — confirms TEXT-only flat-key handling), collapsable_ (15+), website, phone, point_type → all land in OSMAND_EXTENSIONS.

  • 8 <desc>, 0 <cmt, 0 <link> → body policy gets a small workout; *link passthrough is a test-gap* this fixture can't exercise (synthetic case needed).

  • Duplicate-suffixed names exist (William L. Finley ... (1), Bellingham Technical College (2966)) → group+name keying stays unique; dedupe test cases.

  • points_groups present with =pinned=/=hidden= per-group appearance → round-trips via the ARCOLOGY_POINTS_GROUPS file property.

  • Metadata has only <name>favorites</name> → placement falls back to group-name convention (the OsmAnd-rewrote-the-file case).

GeoCoordinate altitude extension

Implemented in the GeoCoordinate block above (the class now lives in this file — see the fold note there). Semantics:

  • altitude: Double? null= on the data class; constructors with two args keep compiling, so existing callers/tests are untouched.

  • parse accepts 2-part (no altitude) and 3-part property values; a garbage, NaN, or Infinity altitude component makes the whole value invalid rather than dropping the altitude silently — identity on the OsmAnd side is never reconstructed from lossy halves, and elevation is in FavouritePoint.equals.

  • toPropertyValue() emits the 3-part form only when altitude is present; pre-altitude property values and any reader that splits on ,.take(2) stay backward-compatible. KML support was removed with the KML export (2026-10-10) — toKmlCoordinates had no remaining consumer.

Tangle targets

  • src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxModels.kt (package computer.whatthefuck.arcology.gis — shared import+export home, DECIDED 2026-10-09; KmlModels stays in export as it's export-only)

  • src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxParser.kt

  • src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxOrgMapping.kt

  • src/commonMain/kotlin/computer/whatthefuck/arcology/gis/WaypointId.kt

  • src/commonMain/kotlin/computer/whatthefuck/arcology/domain/GeoCoordinate.kt (folded in from roam/models.org, same package)

  • Tests: src/commonTest/kotlin/computer/whatthefuck/arcology/gis/{GpxModelsTest,GpxParserTest,WaypointIdTest}.kt, src/commonTest/kotlin/computer/whatthefuck/arcology/domain/GeoCoordinateTest.kt

  • Fixture: src/commonTest/resources/gpx/favorites.gpx — not a tangle target; a raw resource copied from scratch/favorites.gpx, documented in roam/fixtures.org (DECIDED 2026-10-09)

Tests

  • Fixture smoke: 110 wpts, 12 points_groups (incl. "" and personal), extension zoo as bare keys, ele/desc/type counts, unique determinstic IDs across the file, dup-suffix names.

  • Edge cases: nested-tag value drop, blank-value drop, known-prefix strip, prefix:name unknown storage, un-namespaced bare keys, personal-bucket wpts, link passthrough, malformed lat/lon skip + warning, non-GPX content, trk/rte ignored.

  • Mapping: toOrgProperties (2- vs 3-part coords, routed vs unknown keys), orgIdFor precedence (arcology_id > minted), escape round-trips, pointGroups ↔ file properties round-trip, slugify, body helpers.

  • GeoCoordinate: pre-move behavior pinned in GeoCoordinateTest (parse bounds, round-trips, KML order), then altitude cases.

  • WaypointIdTest locks the version-5 formula vectors.

  • Deferred to maps/import-export.org (phase 3): export→parse→export identity and import→export→import semantic-equality property tests — they need the exporter. Coverage floor 50% (contributing.org); KML sits at 0% — don't repeat that.