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.
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.
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
addFavouritebackfills 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.mergeFavoritesdeletes list-equal existing points then adds incoming;checkDuplicateNamesrenames duplicatesname (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
iconcolorbackgroundwpt extensions →OSMAND_ICONOSMAND_COLOROSMAND_BACKGROUND<type>(the OsmAnd group) →ARCOLOGY_GROUPproperty 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 exportUnknown keys → one
OSMAND_EXTENSIONSproperty holding escaped key=value pairs, colon-free withosmand:prefix stripped at capture, rehydrated verbatim on export. TEXT-only in, TEXT-only out.ele→GeoCoordinateoptional altitude;<time>→OSMAND_TIMEproperty;cmtkept 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_idpresent → its value is the org:ID:, updated in place.Native favorite, first contact: uuid5-style deterministic UUID over group + wpt name, built
WaypointId.kton the existingHashUtilsexpect/actual (SHA-256, DECIDED 2026-10-08 — pure commonMain, no new platform code; JVMnameUUIDFromBytesrejected).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.
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.
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.
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.
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
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 &/</> 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.
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
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 inOSMAND_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_groupspresent with =pinned=/=hidden= per-group appearance → round-trips via theARCOLOGY_POINTS_GROUPSfile 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.parseaccepts 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 inFavouritePoint.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) —toKmlCoordinateshad no remaining consumer.
Tangle targets
src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxModels.kt(packagecomputer.whatthefuck.arcology.gis— shared import+export home, DECIDED 2026-10-09; KmlModels stays inexportas it's export-only)src/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxParser.ktsrc/commonMain/kotlin/computer/whatthefuck/arcology/gis/GpxOrgMapping.ktsrc/commonMain/kotlin/computer/whatthefuck/arcology/gis/WaypointId.ktsrc/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.ktFixture:
src/commonTest/resources/gpx/favorites.gpx— not a tangle target; a raw resource copied fromscratch/favorites.gpx, documented in roam/fixtures.org (DECIDED 2026-10-09)
Tests
Fixture smoke: 110 wpts, 12
points_groups(incl.""andpersonal), 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:nameunknown 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),orgIdForprecedence (arcology_id > minted), escape round-trips,pointGroups↔ file properties round-trip, slugify, body helpers.GeoCoordinate: pre-move behavior pinned inGeoCoordinateTest(parse bounds, round-trips, KML order), then altitude cases.WaypointIdTestlocks 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.