Skip to content

Commit 7d37e62

Browse files
authored
Fix/macdive uddf import (Fixes #28) (#42)
* fix: normalize MacDive UDDF namespace and structural quirks before parsing MacDive exports declare a default XML namespace on the root element (xmlns="http://www.streit.cc/uddf/3.2/"), which causes all findElements() calls in the parser to return empty results since elements are resolved against that namespace rather than the empty one. Additionally, MacDive places <country> inside geography/address/country rather than as a direct child of <site>, and puts <equipmentused> inside <informationafterdive> rather than <informationbeforedive>. Adds a UddfNormalizer pre-processing layer that dispatches to dialect- specific handlers before the existing parser runs. The MacDive handler strips the namespace declaration, copies <country> up to the <site> level, and moves <equipmentused> into <informationbeforedive>. No changes to the core parser. UddfFullImportService.importAllDataFromUddf is the single integration point, covering all callers automatically. Adds 25 unit tests covering the normalizer, dialect detection, and end-to-end field mapping (depth, duration, water temp, notes, profile, site country, coordinates, weight). * fix: support MacDive UDDF dialect in import MacDive exports are valid UDDF 3.2.1 but diverge from the canonical form the parser expects in several ways: - Default XML namespace on the root element causes all findElements() calls to return empty results. Strip it from the raw string before parsing to avoid re-serialisation ambiguity. - diveduration and divetime values are float strings (e.g. "3494.00") which int.tryParse() rejects, leaving runtime empty and collapsing all profile waypoint timestamps to 0. Fall back to double.tryParse().round() for both fields. - Site country is nested at geography/address/country instead of being a direct child of the site element. - equipmentused appears inside informationafterdive rather than informationbeforedive where the parser reads it. Adds UddfNormalizer dispatcher and MacDiveDialectNormalizer that pre-processes the raw XML string before the core parser runs. 25 unit tests cover namespace stripping, structural fixes, and full end-to-end import of all key fields. * Fix: wrapped angle brackets with backticks in doc comments * fix: handle float-string passedtime in MacDive UDDF surface interval parsing MacDive exports all numeric values as float strings (e.g. "3600.00"). int.tryParse fails on these, silently dropping surface intervals. Apply the same double.tryParse fallback already used for diveduration and divetime waypoints. * refactor: introduce UddfDialect strategy pattern for dialect-aware UDDF normalization Replace the static MacDiveDialectNormalizer utility class with a proper strategy pattern based on an abstract UddfDialect base class, per maintainer feedback on the PR. Changes: - Add UddfDialect abstract base class with isMatch() and a default no-op normalizeXml() — the single extension point for new dialects - Replace MacDiveDialectNormalizer with MacDiveDialect extends UddfDialect, organised into private _fixEncoding / _fixStructure helpers for readability - Move float-encoded integer normalisation (e.g. "60.00" -> "60") into MacDiveDialect._fixEncoding via regex, removing the need for double-parse fallbacks in the parser - Rewrite UddfNormalizer to iterate a _dialects list, making adding a new dialect a one-line registration - Simplify two int.tryParse fallbacks in UddfFullImportService back to plain int.tryParse — the parser is now fully dialect-unaware The parser no longer contains any dialect-specific logic. A new dialect only requires extending UddfDialect and adding one entry to _dialects. Tests: 28 passing (3 new — float encoding, UddfDialect passthrough, negative isMatch detection) * fix: Update stale namespace comment * fix: harden MacDive dialect detection and normalization safety MacDiveDialect.isMatch previously matched on the UDDF 3.2 namespace alone, which Submersion and Subsurface also use. Detection now checks <generator> tag first, rejecting known non-MacDive exporters, and falls back to structural quirk heuristics only when no generator is present. Also adds idempotency guards to _fixSiteCountry and _moveEquipmentUsed to prevent duplicate elements on repeated normalization, and creates <informationbeforedive> when absent instead of silently skipping the dive (which would lose equipment/weight data).
1 parent e92d5c1 commit 7d37e62

5 files changed

Lines changed: 768 additions & 2 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import 'package:xml/xml.dart';
2+
3+
import 'package:submersion/core/services/export/uddf/uddf_dialect.dart';
4+
5+
/// Normalizes UDDF files exported by MacDive into the canonical form
6+
/// expected by [UddfFullImportService].
7+
///
8+
/// MacDive deviates from the UDDF standard in the following ways:
9+
///
10+
/// **Encoding:**
11+
/// The document root carries a default namespace
12+
/// (`http://www.streit.cc/uddf/3.2/`) which causes [XmlElement.findElements]
13+
/// to return empty results. The namespace is stripped before parsing.
14+
/// MacDive also writes integer-semantics values as float strings (e.g.
15+
/// `<divetime>60.00</divetime>`), which are normalised to plain integers so
16+
/// that standard `int.tryParse()` works in the parser.
17+
///
18+
/// **Structure:**
19+
/// 1. Site country is nested at `geography/address/country` instead of
20+
/// being a direct child of `<site>`.
21+
/// 2. `<equipmentused>` is in `<informationafterdive>` instead of
22+
/// `<informationbeforedive>`.
23+
class MacDiveDialect extends UddfDialect {
24+
static const _namespace = 'http://www.streit.cc/uddf/3.2/';
25+
26+
// Float-encoded integer fields output by MacDive (e.g. "60.00" -> "60").
27+
// MacDive serializes all numeric values as floats regardless of semantics.
28+
static final _floatIntPattern = RegExp(
29+
r'<(divetime|diveduration|divenumber|passedtime|gradientfactorlow|gradientfactorhigh)>(\d+)\.0+</',
30+
);
31+
32+
@override
33+
bool isMatch(XmlDocument doc) {
34+
if (doc.rootElement.namespaceUri != _namespace) return false;
35+
36+
// UDDF-compliant exporters include <generator><name> identifying the app
37+
// (e.g. Subsurface, divelog_convert). When present, use it as the
38+
// authoritative signal to avoid false positives on other apps' exports.
39+
final generators = doc.findAllElements('generator');
40+
if (generators.isNotEmpty) {
41+
return generators.any((gen) {
42+
final name = gen.findElements('name').firstOrNull?.innerText ?? '';
43+
return name.toLowerCase().contains('macdive');
44+
});
45+
}
46+
47+
// No <generator> tag: fall back to structural quirk detection for files
48+
// that lack one (e.g. older MacDive versions or hand-edited UDDF).
49+
return _hasMacDiveStructuralQuirks(doc);
50+
}
51+
52+
// Returns true when the document exhibits structural deviations unique to
53+
// MacDive: country nested under geography/address instead of directly under
54+
// site, or equipmentused placed in informationafterdive.
55+
bool _hasMacDiveStructuralQuirks(XmlDocument doc) {
56+
final hasNestedCountry = doc.findAllElements('site').any((site) {
57+
final geo = site.findElements('geography').firstOrNull;
58+
if (geo == null) return false;
59+
final address = geo.findElements('address').firstOrNull;
60+
if (address == null) return false;
61+
return address.findElements('country').isNotEmpty;
62+
});
63+
64+
final hasEquipmentInAfter = doc.findAllElements('dive').any((dive) {
65+
final after = dive.findElements('informationafterdive').firstOrNull;
66+
if (after == null) return false;
67+
return after.findElements('equipmentused').isNotEmpty;
68+
});
69+
70+
return hasNestedCountry || hasEquipmentInAfter;
71+
}
72+
73+
@override
74+
String normalizeXml(String rawContent) {
75+
final encoded = _fixEncoding(rawContent);
76+
final doc = XmlDocument.parse(encoded);
77+
_fixStructure(doc);
78+
return doc.toXmlString();
79+
}
80+
81+
// Strips the default namespace and normalises float-encoded integer values.
82+
// Both operations run on the raw string before parsing to avoid
83+
// re-serialization side-effects from round-tripping through XmlDocument.
84+
String _fixEncoding(String raw) {
85+
final withoutNamespace = raw.replaceFirst(' xmlns="$_namespace"', '');
86+
return withoutNamespace.replaceAllMapped(
87+
_floatIntPattern,
88+
(m) => '<${m[1]}>${m[2]}</',
89+
);
90+
}
91+
92+
// Corrects element nesting and misplaced elements.
93+
void _fixStructure(XmlDocument doc) {
94+
_fixSiteCountry(doc);
95+
_moveEquipmentUsed(doc);
96+
}
97+
98+
// Copies <country> from geography/address/country to a direct child of
99+
// each <site> element, where the parser expects it.
100+
void _fixSiteCountry(XmlDocument doc) {
101+
for (final site in doc.findAllElements('site')) {
102+
final geo = site.findElements('geography').firstOrNull;
103+
if (geo == null) continue;
104+
final address = geo.findElements('address').firstOrNull;
105+
if (address == null) continue;
106+
final country = address.findElements('country').firstOrNull;
107+
if (country == null) continue;
108+
// Skip if <site> already has a direct <country> child (idempotency).
109+
final hasDirectCountry = site.children.whereType<XmlElement>().any(
110+
(child) => child.name.local == 'country',
111+
);
112+
if (hasDirectCountry) continue;
113+
site.children.add(country.copy());
114+
}
115+
}
116+
117+
// Copies <equipmentused> from <informationafterdive> into
118+
// <informationbeforedive>, which is where the parser reads it.
119+
// Creates <informationbeforedive> if absent so equipment data is not lost.
120+
void _moveEquipmentUsed(XmlDocument doc) {
121+
for (final dive in doc.findAllElements('dive')) {
122+
final after = dive.findElements('informationafterdive').firstOrNull;
123+
if (after == null) continue;
124+
final equip = after.findElements('equipmentused').firstOrNull;
125+
if (equip == null) continue;
126+
var before = dive.findElements('informationbeforedive').firstOrNull;
127+
if (before == null) {
128+
before = XmlElement(XmlName('informationbeforedive'));
129+
dive.children.add(before);
130+
}
131+
// Skip if informationbeforedive already has equipmentused (idempotency).
132+
if (before.findElements('equipmentused').isNotEmpty) continue;
133+
before.children.add(equip.copy());
134+
}
135+
}
136+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import 'package:xml/xml.dart';
2+
3+
/// Abstract base class for UDDF dialect handlers.
4+
///
5+
/// A dialect encapsulates all vendor-specific deviations from the UDDF
6+
/// standard and normalizes them into the canonical form expected by
7+
/// [UddfFullImportService] before parsing begins. The parser is fully
8+
/// dialect-unaware.
9+
///
10+
/// Implementors only need to override [isMatch] and [normalizeXml].
11+
///
12+
/// To add support for a new dialect:
13+
/// 1. Create a class extending [UddfDialect].
14+
/// 2. Override [isMatch] to detect the dialect's document signature.
15+
/// 3. Override [normalizeXml] to transform the XML into canonical form.
16+
/// 4. Register an instance in [UddfNormalizer._dialects].
17+
///
18+
/// Detection ordering in [UddfNormalizer._dialects] matters — more specific
19+
/// detectors should be listed before more general ones.
20+
abstract class UddfDialect {
21+
/// Returns true when [doc] is an export from the dialect this class handles.
22+
bool isMatch(XmlDocument doc);
23+
24+
/// Transforms [rawContent] into the canonical UDDF form expected by the
25+
/// parser. The default implementation returns [rawContent] unchanged.
26+
///
27+
/// Implementations typically handle three categories of deviation:
28+
/// - Encoding quirks (e.g. wrong namespaces, float-encoded integers)
29+
/// - Structural quirks (e.g. misplaced or mis-nested elements)
30+
/// - Missing elements (e.g. injecting implied defaults as XML nodes)
31+
String normalizeXml(String rawContent) => rawContent;
32+
}

lib/core/services/export/uddf/uddf_full_import_service.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:xml/xml.dart';
33
import 'package:submersion/core/constants/enums.dart' as enums;
44
import 'package:submersion/core/services/export/models/uddf_import_result.dart';
55
import 'package:submersion/core/services/export/uddf/uddf_import_parsers.dart';
6+
import 'package:submersion/core/services/export/uddf/uddf_normalizer.dart';
67
import 'package:submersion/features/dive_log/domain/entities/dive.dart';
78

89
/// Handles comprehensive UDDF import including all application data.
@@ -15,8 +16,9 @@ class UddfFullImportService {
1516
/// Import ALL application data from UDDF file.
1617
/// Returns [UddfImportResult] with all parsed data.
1718
Future<UddfImportResult> importAllDataFromUddf(String uddfContent) async {
18-
final document = XmlDocument.parse(uddfContent);
19-
// Use rootElement instead of findElements to handle XML namespaces properly
19+
final normalized = UddfNormalizer.normalize(uddfContent);
20+
final document = XmlDocument.parse(normalized);
21+
// Namespace handling is performed by UddfNormalizer before parsing
2022
final uddfElement = document.rootElement;
2123
if (uddfElement.name.local != 'uddf') {
2224
throw const FormatException(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'package:xml/xml.dart';
2+
3+
import 'package:submersion/core/services/export/uddf/dialects/macdive_dialect.dart';
4+
import 'package:submersion/core/services/export/uddf/uddf_dialect.dart';
5+
6+
/// Pre-processes UDDF content by applying whichever [UddfDialect] matches,
7+
/// returning canonical UDDF that [UddfFullImportService] can parse without
8+
/// any dialect-specific logic.
9+
///
10+
/// To support a new dialect, create a class extending [UddfDialect] and add
11+
/// an instance to [_dialects]. More specific detectors must come before more
12+
/// general ones to avoid false positives.
13+
class UddfNormalizer {
14+
const UddfNormalizer._();
15+
16+
/// Registered dialects in detection order. More specific detectors first.
17+
static final List<UddfDialect> _dialects = [
18+
MacDiveDialect(),
19+
// Add new dialects here (e.g. ShearwaterDialect(), SubsurfaceDialect()).
20+
];
21+
22+
/// Returns a normalized copy of [content], applying whichever dialect
23+
/// matches, or returns [content] unchanged if no dialect matches.
24+
static String normalize(String content) {
25+
final doc = XmlDocument.parse(content);
26+
for (final dialect in _dialects) {
27+
if (dialect.isMatch(doc)) {
28+
return dialect.normalizeXml(content);
29+
}
30+
}
31+
return content;
32+
}
33+
}

0 commit comments

Comments
 (0)