Skip to main content

qualia_core_db/platform/
kml_bridge.rs

1//! KML import/export bridge.
2//!
3//! KML `<Placemark>` → NQuin stream using GeoSPARQL predicates (internal storage)
4//! and PROV-O temporal quins for `<TimeStamp>` / `<TimeSpan>`.
5//!
6//! Named graph context IDs:
7//!   SPATIAL_CONTEXT  — geometry quins (`geo:hasGeometry`, `geo:asWKT`)
8//!   T_CONTEXT        — temporal quins (`prov:generatedAtTime`, `prov:startedAtTime`, `prov:endedAtTime`)
9
10use quick_xml::events::Event;
11use quick_xml::Reader;
12
13use crate::{q_hash, NQuin};
14
15// ── Named-graph context IDs ───────────────────────────────────────────────────
16pub const SPATIAL_CONTEXT: u64 = q_hash("urn:qualia:context:spatial");
17pub const T_CONTEXT: u64 = q_hash("urn:qualia:context:temporal");
18
19// ── GeoSPARQL predicate hashes ────────────────────────────────────────────────
20pub const P_HAS_GEOMETRY: u64 = q_hash("http://www.opengis.net/ont/geosparql#hasGeometry");
21pub const P_AS_WKT: u64 = q_hash("http://www.opengis.net/ont/geosparql#asWKT");
22
23// ── PROV-O predicate hashes ───────────────────────────────────────────────────
24pub const P_GENERATED_AT: u64 = q_hash("http://www.w3.org/ns/prov#generatedAtTime");
25pub const P_STARTED_AT: u64 = q_hash("http://www.w3.org/ns/prov#startedAtTime");
26pub const P_ENDED_AT: u64 = q_hash("http://www.w3.org/ns/prov#endedAtTime");
27
28// ── Dublin Core predicate hashes ─────────────────────────────────────────────
29const P_TITLE: u64 = q_hash("http://purl.org/dc/terms/title");
30
31/// Error type for KML import/export operations.
32#[derive(Debug)]
33pub enum KmlError {
34    Xml(String),
35    InvalidGeometry(String),
36}
37
38impl std::fmt::Display for KmlError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            KmlError::Xml(s) => write!(f, "KML XML error: {s}"),
42            KmlError::InvalidGeometry(s) => write!(f, "KML geometry error: {s}"),
43        }
44    }
45}
46
47impl std::error::Error for KmlError {}
48
49/// Parse a KML document and return a flat stream of NQuins.
50///
51/// Each `<Placemark>` becomes:
52/// - One `geo:hasGeometry` quin in `SPATIAL_CONTEXT` (object = GeoHash-64 of centroid)
53/// - One `geo:asWKT` quin in `SPATIAL_CONTEXT` (object = hash of WKT string)
54/// - Zero or more PROV-O temporal quins in `T_CONTEXT`
55/// - One `dcterms:title` quin if `<name>` is present
56///
57/// The lexicon map (hash → string) for any literal values is returned alongside.
58pub fn import_kml(
59    bytes: &[u8],
60) -> Result<(Vec<NQuin>, std::collections::HashMap<u64, String>), KmlError> {
61    let mut reader = Reader::from_reader(bytes);
62    reader.config_mut().trim_text(true);
63
64    let mut quins: Vec<NQuin> = Vec::new();
65    let mut lexicon: std::collections::HashMap<u64, String> = std::collections::HashMap::new();
66    let mut buf = Vec::new();
67
68    // Placemark / Point state
69    let mut in_placemark = false;
70    let mut in_point = false;
71    let mut in_timestamp = false;
72    let mut in_timespan = false;
73    let mut in_name = false;
74    let mut in_coordinates = false;
75    let mut in_when = false;
76    let mut in_begin = false;
77    let mut in_end = false;
78
79    // Polygon state (Phase 2)
80    let mut in_polygon = false;
81    let mut in_outer_boundary = false;
82    let mut in_linear_ring = false;
83    let mut in_poly_coordinates = false;
84    let mut polygon_coordinates_text = String::new();
85
86    // NetworkLink state (Phase 2)
87    let mut in_network_link = false;
88    let mut in_link = false;
89    let mut in_href = false;
90    let mut href_text = String::new();
91    let mut network_link_name = String::new();
92    let mut network_link_idx: u64 = 0;
93
94    let mut placemark_subject: u64 = 0;
95    let mut placemark_idx: u64 = 0;
96    let mut coordinates_text = String::new();
97    let mut name_text = String::new();
98    let mut when_text = String::new();
99    let mut begin_text = String::new();
100    let mut end_text = String::new();
101
102    loop {
103        match reader.read_event_into(&mut buf) {
104            Ok(Event::Start(e)) => {
105                let local = e.local_name();
106                match local.as_ref() {
107                    b"Placemark" => {
108                        placemark_idx += 1;
109                        placemark_subject =
110                            fnv_hash(format!("kml:placemark:{placemark_idx}").as_bytes());
111                        in_placemark = true;
112                        in_polygon = false;
113                        coordinates_text.clear();
114                        polygon_coordinates_text.clear();
115                        name_text.clear();
116                        when_text.clear();
117                        begin_text.clear();
118                        end_text.clear();
119                    }
120                    b"Point" if in_placemark => in_point = true,
121                    b"TimeStamp" if in_placemark => in_timestamp = true,
122                    b"TimeSpan" if in_placemark => in_timespan = true,
123                    b"name" if in_placemark => in_name = true,
124                    b"name" if in_network_link => in_name = true,
125                    b"coordinates" if in_point => in_coordinates = true,
126                    b"when" if in_timestamp => in_when = true,
127                    b"begin" if in_timespan => in_begin = true,
128                    b"end" if in_timespan => in_end = true,
129                    // Polygon (Phase 2)
130                    b"Polygon" if in_placemark => in_polygon = true,
131                    b"outerBoundaryIs" if in_polygon => in_outer_boundary = true,
132                    b"LinearRing" if in_outer_boundary => in_linear_ring = true,
133                    b"coordinates" if in_linear_ring => in_poly_coordinates = true,
134                    // NetworkLink (Phase 2)
135                    b"NetworkLink" => {
136                        network_link_idx += 1;
137                        in_network_link = true;
138                        href_text.clear();
139                        network_link_name.clear();
140                    }
141                    b"Link" if in_network_link => in_link = true,
142                    b"href" if in_link => in_href = true,
143                    _ => {}
144                }
145            }
146            Ok(Event::End(e)) => {
147                let local = e.local_name();
148                match local.as_ref() {
149                    b"Placemark" if in_placemark => {
150                        flush_placemark(
151                            placemark_subject,
152                            &coordinates_text,
153                            &name_text,
154                            &when_text,
155                            &begin_text,
156                            &end_text,
157                            &mut quins,
158                            &mut lexicon,
159                        )?;
160                        if in_polygon && !polygon_coordinates_text.is_empty() {
161                            flush_polygon(
162                                placemark_subject,
163                                &polygon_coordinates_text,
164                                &name_text,
165                                &mut quins,
166                                &mut lexicon,
167                            )?;
168                        }
169                        in_placemark = false;
170                        in_point = false;
171                        in_timestamp = false;
172                        in_timespan = false;
173                        in_polygon = false;
174                        in_outer_boundary = false;
175                        in_linear_ring = false;
176                    }
177                    b"Point" => in_point = false,
178                    b"TimeStamp" => in_timestamp = false,
179                    b"TimeSpan" => in_timespan = false,
180                    b"name" => {
181                        in_name = false;
182                        if in_network_link {
183                            network_link_name = name_text.clone();
184                        }
185                    }
186                    b"coordinates" if !in_poly_coordinates => in_coordinates = false,
187                    b"when" => in_when = false,
188                    b"begin" => in_begin = false,
189                    b"end" => in_end = false,
190                    // Polygon (Phase 2)
191                    b"Polygon" => in_polygon = false,
192                    b"outerBoundaryIs" => in_outer_boundary = false,
193                    b"LinearRing" => in_linear_ring = false,
194                    b"coordinates" if in_poly_coordinates => in_poly_coordinates = false,
195                    // NetworkLink (Phase 2)
196                    b"NetworkLink" if in_network_link => {
197                        flush_network_link(
198                            network_link_idx,
199                            &href_text,
200                            &network_link_name,
201                            &mut quins,
202                            &mut lexicon,
203                        );
204                        in_network_link = false;
205                        in_link = false;
206                    }
207                    b"Link" => in_link = false,
208                    b"href" => in_href = false,
209                    _ => {}
210                }
211            }
212            Ok(Event::Text(e)) => {
213                // quick-xml 0.40: `BytesText::unescape` was removed. Reproduce its
214                // behaviour by decoding the bytes then unescaping XML entities.
215                let decoded = e.decode().map_err(|e| KmlError::Xml(e.to_string()))?;
216                let text = quick_xml::escape::unescape(&decoded)
217                    .map_err(|e| KmlError::Xml(e.to_string()))?
218                    .into_owned();
219                if in_poly_coordinates {
220                    polygon_coordinates_text = text;
221                } else if in_coordinates {
222                    coordinates_text = text;
223                } else if in_name {
224                    name_text = text;
225                } else if in_when {
226                    when_text = text;
227                } else if in_begin {
228                    begin_text = text;
229                } else if in_end {
230                    end_text = text;
231                } else if in_href {
232                    href_text = text;
233                }
234            }
235            Ok(Event::Eof) => break,
236            Err(e) => return Err(KmlError::Xml(e.to_string())),
237            _ => {}
238        }
239        buf.clear();
240    }
241
242    Ok((quins, lexicon))
243}
244
245/// Build NQuins for one Placemark and append them to `quins`.
246fn flush_placemark(
247    subject: u64,
248    coordinates: &str,
249    name: &str,
250    when: &str,
251    begin: &str,
252    end: &str,
253    quins: &mut Vec<NQuin>,
254    lexicon: &mut std::collections::HashMap<u64, String>,
255) -> Result<(), KmlError> {
256    if coordinates.is_empty() {
257        return Ok(());
258    }
259
260    // Parse KML coordinate string: "lon,lat[,alt] ..."
261    let (lon, lat) = parse_first_coordinate(coordinates)?;
262    let wkt = format!("POINT({lon} {lat})");
263    let wkt_hash = fnv_hash(wkt.as_bytes());
264    lexicon.insert(wkt_hash, wkt);
265
266    // GeoHash-64: encode lon/lat into a u64 bit-interleave (simplified)
267    let geohash = encode_geohash_64(lon, lat);
268
269    quins.push(make_quin(subject, P_HAS_GEOMETRY, geohash, SPATIAL_CONTEXT));
270    quins.push(make_quin(subject, P_AS_WKT, wkt_hash, SPATIAL_CONTEXT));
271
272    // title
273    if !name.is_empty() {
274        let name_hash = fnv_hash(name.as_bytes());
275        lexicon.insert(name_hash, name.to_owned());
276        quins.push(make_quin(subject, P_TITLE, name_hash, SPATIAL_CONTEXT));
277    }
278
279    // PROV-O temporal
280    if !when.is_empty() {
281        let ts = parse_iso8601_ms(when).unwrap_or(0);
282        quins.push(make_temporal_quin(subject, P_GENERATED_AT, ts));
283    }
284    if !begin.is_empty() {
285        let ts = parse_iso8601_ms(begin).unwrap_or(0);
286        quins.push(make_temporal_quin(subject, P_STARTED_AT, ts));
287    }
288    if !end.is_empty() {
289        let ts = parse_iso8601_ms(end).unwrap_or(0);
290        quins.push(make_temporal_quin(subject, P_ENDED_AT, ts));
291    }
292
293    Ok(())
294}
295
296// ── Predicate hash used for NetworkLink DID pointer ───────────────────────────
297const P_SEE_ALSO: u64 = q_hash("http://www.w3.org/2000/01/rdf-schema#seeAlso");
298const P_BOUNDING_BOX: u64 = q_hash("urn:qualia:spatial:boundingBox");
299const P_BB_MIN_LON: u64 = q_hash("urn:qualia:spatial:minLon");
300const P_BB_MAX_LON: u64 = q_hash("urn:qualia:spatial:maxLon");
301const P_BB_MIN_LAT: u64 = q_hash("urn:qualia:spatial:minLat");
302const P_BB_MAX_LAT: u64 = q_hash("urn:qualia:spatial:maxLat");
303
304/// Build NQuins for a `<Polygon>` element and append them to `quins`.
305///
306/// Returns:
307/// - `geo:hasGeometry` = GeoHash-64 of polygon centroid
308/// - `geo:asWKT` = hash of WKT POLYGON string
309/// - Bounding box quins (`urn:qualia:spatial:minLon/maxLon/minLat/maxLat`)
310fn flush_polygon(
311    subject: u64,
312    coordinates_text: &str,
313    name: &str,
314    quins: &mut Vec<NQuin>,
315    lexicon: &mut std::collections::HashMap<u64, String>,
316) -> Result<(), KmlError> {
317    let points = parse_coordinate_list(coordinates_text)?;
318    if points.is_empty() {
319        return Ok(());
320    }
321
322    // Compute bounding box and centroid.
323    let mut min_lon = f64::MAX;
324    let mut max_lon = f64::MIN;
325    let mut min_lat = f64::MAX;
326    let mut max_lat = f64::MIN;
327    let mut sum_lon = 0.0f64;
328    let mut sum_lat = 0.0f64;
329    for &(lon, lat) in &points {
330        min_lon = min_lon.min(lon);
331        max_lon = max_lon.max(lon);
332        min_lat = min_lat.min(lat);
333        max_lat = max_lat.max(lat);
334        sum_lon += lon;
335        sum_lat += lat;
336    }
337    let n = points.len() as f64;
338    let centroid_lon = sum_lon / n;
339    let centroid_lat = sum_lat / n;
340
341    // WKT POLYGON((lon1 lat1, lon2 lat2, ...))
342    // KML convention: first and last coordinates must match to close the ring.
343    let ring: Vec<String> = points
344        .iter()
345        .map(|(lon, lat)| format!("{lon} {lat}"))
346        .collect();
347    let wkt = format!("POLYGON(({})", ring.join(", "));
348    let wkt = wkt + ")";
349    let wkt_hash = fnv_hash(wkt.as_bytes());
350    lexicon.insert(wkt_hash, wkt);
351
352    let geohash = encode_geohash_64(centroid_lon, centroid_lat);
353    quins.push(make_quin(subject, P_HAS_GEOMETRY, geohash, SPATIAL_CONTEXT));
354    quins.push(make_quin(subject, P_AS_WKT, wkt_hash, SPATIAL_CONTEXT));
355
356    // Bounding box quins — encode as f64 bits in the object field.
357    let bb_subject = fnv_hash(format!("kml:bbox:{subject}").as_bytes());
358    quins.push(make_quin(
359        subject,
360        P_BOUNDING_BOX,
361        bb_subject,
362        SPATIAL_CONTEXT,
363    ));
364    quins.push(make_quin(
365        bb_subject,
366        P_BB_MIN_LON,
367        min_lon.to_bits(),
368        SPATIAL_CONTEXT,
369    ));
370    quins.push(make_quin(
371        bb_subject,
372        P_BB_MAX_LON,
373        max_lon.to_bits(),
374        SPATIAL_CONTEXT,
375    ));
376    quins.push(make_quin(
377        bb_subject,
378        P_BB_MIN_LAT,
379        min_lat.to_bits(),
380        SPATIAL_CONTEXT,
381    ));
382    quins.push(make_quin(
383        bb_subject,
384        P_BB_MAX_LAT,
385        max_lat.to_bits(),
386        SPATIAL_CONTEXT,
387    ));
388
389    if !name.is_empty() {
390        let name_hash = fnv_hash(name.as_bytes());
391        lexicon.insert(name_hash, name.to_owned());
392        quins.push(make_quin(subject, P_TITLE, name_hash, SPATIAL_CONTEXT));
393    }
394
395    Ok(())
396}
397
398/// Parse a full KML coordinate list string into a `Vec<(lon, lat)>`.
399///
400/// Handles both space-separated and newline-separated coordinate tuples of the
401/// form `"lon,lat[,alt]"`.
402fn parse_coordinate_list(s: &str) -> Result<Vec<(f64, f64)>, KmlError> {
403    let mut out = Vec::new();
404    for token in s.split_whitespace() {
405        // Re-use the existing single-coordinate parser (parses "first token only").
406        let (lon, lat) = parse_first_coordinate(token)?;
407        out.push((lon, lat));
408    }
409    Ok(out)
410}
411
412/// Build NQuins for a `<NetworkLink>` element.
413///
414/// - If `href` looks like a DID URI (`did:...`), a `rdfs:seeAlso` quin records it in `SPATIAL_CONTEXT`.
415/// - Otherwise the href hash is stored as a `rdfs:seeAlso` string reference.
416fn flush_network_link(
417    idx: u64,
418    href: &str,
419    name: &str,
420    quins: &mut Vec<NQuin>,
421    lexicon: &mut std::collections::HashMap<u64, String>,
422) {
423    if href.is_empty() {
424        return;
425    }
426    let link_subject = fnv_hash(format!("kml:networklink:{idx}").as_bytes());
427    let href_hash = fnv_hash(href.as_bytes());
428    lexicon.insert(href_hash, href.to_owned());
429
430    quins.push(make_quin(
431        link_subject,
432        P_SEE_ALSO,
433        href_hash,
434        SPATIAL_CONTEXT,
435    ));
436
437    if !name.is_empty() {
438        let name_hash = fnv_hash(name.as_bytes());
439        lexicon.insert(name_hash, name.to_owned());
440        quins.push(make_quin(link_subject, P_TITLE, name_hash, SPATIAL_CONTEXT));
441    }
442}
443
444/// Export a slice of NQuins (SPATIAL_CONTEXT + T_CONTEXT) back to a KML document string.
445///
446/// NQuins outside the two spatial/temporal contexts are ignored.
447/// Geometry is reconstructed from the `geo:asWKT` object hash by lookup in `lexicon`.
448pub fn export_kml(quins: &[NQuin], lexicon: &std::collections::HashMap<u64, String>) -> String {
449    use std::collections::BTreeMap;
450
451    // Group quins by subject
452    let mut by_subject: BTreeMap<u64, PlacemarkData> = BTreeMap::new();
453
454    for q in quins {
455        if q.context != SPATIAL_CONTEXT && q.context != T_CONTEXT {
456            continue;
457        }
458        let entry = by_subject.entry(q.subject).or_default();
459        match q.predicate {
460            P_AS_WKT => {
461                if let Some(wkt) = lexicon.get(&q.object) {
462                    entry.wkt = Some(wkt.clone());
463                }
464            }
465            P_TITLE => {
466                if let Some(title) = lexicon.get(&q.object) {
467                    entry.name = Some(title.clone());
468                }
469            }
470            P_GENERATED_AT => entry.when_ms = Some(q.object),
471            P_STARTED_AT => entry.begin_ms = Some(q.object),
472            P_ENDED_AT => entry.end_ms = Some(q.object),
473            _ => {}
474        }
475    }
476
477    let mut out = String::from(
478        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
479         <kml xmlns=\"http://www.opengis.net/kml/2.2\">\n<Document>\n",
480    );
481
482    for (_subj, pm) in &by_subject {
483        out.push_str("<Placemark>\n");
484        if let Some(name) = &pm.name {
485            out.push_str(&format!("  <name>{}</name>\n", xml_escape(name)));
486        }
487        if let Some(wkt) = &pm.wkt {
488            if let Some((lon, lat)) = wkt_to_lonlat(wkt) {
489                out.push_str(&format!(
490                    "  <Point><coordinates>{lon},{lat},0</coordinates></Point>\n"
491                ));
492            }
493        }
494        match (pm.when_ms, pm.begin_ms, pm.end_ms) {
495            (Some(ms), _, _) => {
496                let ts = ms_to_iso8601(ms);
497                out.push_str(&format!("  <TimeStamp><when>{ts}</when></TimeStamp>\n"));
498            }
499            (_, Some(b), Some(e)) => {
500                out.push_str(&format!(
501                    "  <TimeSpan><begin>{}</begin><end>{}</end></TimeSpan>\n",
502                    ms_to_iso8601(b),
503                    ms_to_iso8601(e)
504                ));
505            }
506            _ => {}
507        }
508        out.push_str("</Placemark>\n");
509    }
510
511    out.push_str("</Document>\n</kml>");
512    out
513}
514
515// ── Internal helpers ──────────────────────────────────────────────────────────
516
517#[derive(Default)]
518struct PlacemarkData {
519    wkt: Option<String>,
520    name: Option<String>,
521    when_ms: Option<u64>,
522    begin_ms: Option<u64>,
523    end_ms: Option<u64>,
524}
525
526#[inline]
527fn make_quin(subject: u64, predicate: u64, object: u64, context: u64) -> NQuin {
528    NQuin {
529        subject,
530        predicate,
531        object,
532        context,
533        metadata: 0,
534        parity: 0,
535    }
536}
537
538#[inline]
539fn make_temporal_quin(subject: u64, predicate: u64, timestamp_ms: u64) -> NQuin {
540    NQuin {
541        subject,
542        predicate,
543        object: timestamp_ms,
544        context: T_CONTEXT,
545        metadata: 0,
546        parity: 0,
547    }
548}
549
550/// FNV-1a — matches `crate::q_hash` (60-bit identity) but operates on `&[u8]` for
551/// runtime strings, so geo-feature IRIs hashed here share the ONE identity space
552/// and join terms hashed via q_hash / generate_60bit_token.
553#[inline]
554fn fnv_hash(bytes: &[u8]) -> u64 {
555    let mut h: u64 = 0xcbf29ce484222325;
556    for &b in bytes {
557        h ^= b as u64;
558        h = h.wrapping_mul(0x100000001b3);
559    }
560    h & 0x0FFF_FFFF_FFFF_FFFF
561}
562
563/// Encode (lon, lat) into a 64-bit interleaved GeoHash integer.
564/// Uses 32 bits each for latitude and longitude mapped to [0, 2^32).
565pub fn encode_geohash_64(lon: f64, lat: f64) -> u64 {
566    let lon_u = ((lon + 180.0) / 360.0 * u32::MAX as f64) as u64;
567    let lat_u = ((lat + 90.0) / 180.0 * u32::MAX as f64) as u64;
568    // Bit-interleave: even bits = lon, odd bits = lat
569    let mut result: u64 = 0;
570    for i in 0..32u64 {
571        result |= ((lon_u >> i) & 1) << (i * 2);
572        result |= ((lat_u >> i) & 1) << (i * 2 + 1);
573    }
574    result
575}
576
577/// Parse the first `lon,lat[,alt]` triple from a KML coordinates string.
578fn parse_first_coordinate(s: &str) -> Result<(f64, f64), KmlError> {
579    let first = s.split_whitespace().next().unwrap_or(s);
580    let mut parts = first.split(',');
581    let lon: f64 = parts
582        .next()
583        .and_then(|v| v.trim().parse().ok())
584        .ok_or_else(|| KmlError::InvalidGeometry(format!("bad longitude in '{s}'")))?;
585    let lat: f64 = parts
586        .next()
587        .and_then(|v| v.trim().parse().ok())
588        .ok_or_else(|| KmlError::InvalidGeometry(format!("bad latitude in '{s}'")))?;
589    Ok((lon, lat))
590}
591
592/// Extract lon/lat from `POINT(lon lat)` WKT.
593fn wkt_to_lonlat(wkt: &str) -> Option<(f64, f64)> {
594    let inner = wkt.trim_start_matches("POINT(").trim_end_matches(')');
595    let mut parts = inner.split_whitespace();
596    let lon: f64 = parts.next()?.parse().ok()?;
597    let lat: f64 = parts.next()?.parse().ok()?;
598    Some((lon, lat))
599}
600
601/// Parse an ISO 8601 datetime string into milliseconds since Unix epoch.
602/// Supports `YYYY-MM-DDThh:mm:ssZ` and date-only `YYYY-MM-DD`.
603fn parse_iso8601_ms(s: &str) -> Option<u64> {
604    let s = s.trim().trim_end_matches('Z');
605    // Try full datetime first
606    if s.len() >= 19 {
607        let (date, time) = s.split_at(10);
608        let time = time.trim_start_matches('T');
609        let (y, m, d) = parse_date(date)?;
610        let (hh, mm, ss) = parse_time(time)?;
611        let days = days_since_epoch(y, m, d)?;
612        let secs = days as u64 * 86400 + hh as u64 * 3600 + mm as u64 * 60 + ss as u64;
613        return Some(secs * 1000);
614    }
615    // Date-only
616    if s.len() == 10 {
617        let (y, m, d) = parse_date(s)?;
618        let days = days_since_epoch(y, m, d)?;
619        return Some(days as u64 * 86400 * 1000);
620    }
621    None
622}
623
624fn parse_date(s: &str) -> Option<(i32, u8, u8)> {
625    let mut parts = s.split('-');
626    let y: i32 = parts.next()?.parse().ok()?;
627    let m: u8 = parts.next()?.parse().ok()?;
628    let d: u8 = parts.next()?.parse().ok()?;
629    Some((y, m, d))
630}
631
632fn parse_time(s: &str) -> Option<(u8, u8, u8)> {
633    let mut parts = s.split(':');
634    let hh: u8 = parts.next()?.parse().ok()?;
635    let mm: u8 = parts.next()?.parse().ok()?;
636    let ss: u8 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
637    Some((hh, mm, ss))
638}
639
640/// Days from 1970-01-01 to `year-month-day` (Gregorian). Returns None if date is invalid.
641fn days_since_epoch(y: i32, m: u8, d: u8) -> Option<i64> {
642    if m < 1 || m > 12 || d < 1 || d > 31 {
643        return None;
644    }
645    // Civil calendar algorithm (from Howard Hinnant's civil_from_days inverse)
646    let y = y as i64 - if m <= 2 { 1 } else { 0 };
647    let era = y.div_euclid(400);
648    let yoe = y.rem_euclid(400);
649    let doy = (153 * (m as i64 + if m > 2 { -3 } else { 9 }) + 2) / 5 + d as i64 - 1;
650    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
651    Some(era * 146097 + doe - 719468)
652}
653
654/// Convert milliseconds since Unix epoch to `YYYY-MM-DDThh:mm:ssZ`.
655fn ms_to_iso8601(ms: u64) -> String {
656    let secs = ms / 1000;
657    let s = secs % 60;
658    let m = (secs / 60) % 60;
659    let h = (secs / 3600) % 24;
660    let days = secs / 86400;
661    let (y, mo, d) = civil_from_days(days as i64);
662    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
663}
664
665/// Convert days since epoch to (year, month, day).
666fn civil_from_days(z: i64) -> (i32, u32, u32) {
667    let z = z + 719468;
668    let era = z.div_euclid(146097);
669    let doe = z.rem_euclid(146097) as u64;
670    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
671    let y = yoe as i64 + era * 400;
672    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
673    let mp = (5 * doy + 2) / 153;
674    let d = doy - (153 * mp + 2) / 5 + 1;
675    let m = if mp < 10 { mp + 3 } else { mp - 9 };
676    let y = if m <= 2 { y + 1 } else { y };
677    (y as i32, m as u32, d as u32)
678}
679
680fn xml_escape(s: &str) -> String {
681    s.replace('&', "&amp;")
682        .replace('<', "&lt;")
683        .replace('>', "&gt;")
684        .replace('"', "&quot;")
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn roundtrip_simple_point() {
693        let kml = br#"<?xml version="1.0"?>
694<kml xmlns="http://www.opengis.net/kml/2.2">
695<Document>
696  <Placemark>
697    <name>Test Point</name>
698    <Point><coordinates>-122.0839,37.4219,0</coordinates></Point>
699    <TimeStamp><when>2024-01-15T12:00:00Z</when></TimeStamp>
700  </Placemark>
701</Document>
702</kml>"#;
703        let (quins, lex) = import_kml(kml).unwrap();
704        assert!(!quins.is_empty(), "should produce quins");
705
706        let spatial: Vec<_> = quins
707            .iter()
708            .filter(|q| q.context == SPATIAL_CONTEXT)
709            .collect();
710        let temporal: Vec<_> = quins.iter().filter(|q| q.context == T_CONTEXT).collect();
711        assert!(!spatial.is_empty(), "spatial quins expected");
712        assert!(!temporal.is_empty(), "temporal quins expected");
713
714        let exported = export_kml(&quins, &lex);
715        assert!(
716            exported.contains("POINT") || exported.contains("-122"),
717            "WKT or coords in export"
718        );
719    }
720
721    #[test]
722    fn timespan_produces_start_end_quins() {
723        let kml = br#"<?xml version="1.0"?>
724<kml xmlns="http://www.opengis.net/kml/2.2">
725<Document>
726  <Placemark>
727    <Point><coordinates>10.0,20.0,0</coordinates></Point>
728    <TimeSpan>
729      <begin>2020-06-01</begin>
730      <end>2020-12-31</end>
731    </TimeSpan>
732  </Placemark>
733</Document>
734</kml>"#;
735        let (quins, _) = import_kml(kml).unwrap();
736        let has_start = quins.iter().any(|q| q.predicate == P_STARTED_AT);
737        let has_end = quins.iter().any(|q| q.predicate == P_ENDED_AT);
738        assert!(has_start, "expected prov:startedAtTime quin");
739        assert!(has_end, "expected prov:endedAtTime quin");
740    }
741
742    #[test]
743    fn geohash_64_is_deterministic() {
744        let h1 = encode_geohash_64(-122.0, 37.4);
745        let h2 = encode_geohash_64(-122.0, 37.4);
746        assert_eq!(h1, h2);
747    }
748}