Skip to content

Commit dda9cf7

Browse files
committed
fix(macdive-sqlite): address PR #256 round 2 review (equipmentRefs, probe dedup, doc fixes)
Round 2 of Copilot's review on PR #256 — 7 items, 5 implemented and 2 doc/rename fixes. - equipmentRefs (the big one): my earlier pushback was wrong. `UddfEntityImporter` DOES resolve `diveData['equipmentRefs']` via `equipmentIdMapping`, so SQLite imports now emit per-dive equipmentRefs pulled from `diveToGearPks`. Each gear item gets a stable `uddfId` (MacDive UUID, with name fallback) so the importer can link gear back to dives the same way M2 XML would. Also filters name-less gear rows so `_importEquipment` doesn't silently drop entries while `equipmentIdMapping` keeps stale refs. - Format detector: probe SQLite table names ONCE per input, not once per flavor check. Previously detection wrote the full byte array to a temp file + opened sqlite twice (once for Shearwater, once for MacDive). Added `probeSqliteTableNames` on ShearwaterDbReader plus sync `matchesTables` on both readers so the detector can probe once and match all flavors. - Doc fix: `BPlistDecoder.decode` docstring no longer lists UIDs as unsupported — they've been supported since M3. - Doc fix: explicit comment at the bplist int-decode case explaining the 16-byte best-effort low-64-bit truncation behavior (implicit via Dart int overflow on `(value << 8) | byte`). - Doc+code: `MacDiveDbReader.readAll` now validates required tables up front and throws a clear FormatException if any are missing, so the docstring's "validated up-front" promise matches reality even for callers that bypass `isMacDiveDb`. - Rename: `samplesBplist` and `rawDataBplist` → `samplesBlob` / `rawDataBlob`. The data in these fields is NOT bplist (ZSAMPLES is MacDive-proprietary, ZRAWDATA is raw sensor dump); the old names would have misled future callers into trying to decode them. - Tests: 2 new synthetic-DB parser tests covering uddfId emission and equipmentRefs resolution; 1 new real-sample test verifying every emitted equipmentRef points at an equipment uddfId.
1 parent 81c78d7 commit dda9cf7

8 files changed

Lines changed: 173 additions & 22 deletions

File tree

lib/core/utils/bplist/bplist_decoder.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class BPlistDecoder {
2727

2828
/// Entry point: decode [bytes] into the root [BPlistObject].
2929
/// Throws [FormatException] if [bytes] is not a valid bplist00 stream
30-
/// or uses unsupported type markers (sets, UIDs).
30+
/// or uses unsupported type markers (sets).
3131
static BPlistObject decode(Uint8List bytes) {
3232
if (bytes.length < 8 + 32) {
3333
throw const FormatException('bplist00 stream too short');
@@ -79,6 +79,12 @@ class BPlistDecoder {
7979

8080
case 0x1: // int
8181
// info bits 0..3 encode log2(byteCount): 0->1, 1->2, 2->4, 3->8, 4->16.
82+
// 16-byte ints are decoded as a best-effort truncation to the low
83+
// 64 bits: Dart's `int` is 64-bit on native platforms, and the
84+
// `(value << 8) | byte` accumulator in `_readBigEndianInt` silently
85+
// drops the high bytes once the value overflows — which for a big-
86+
// endian read keeps exactly the low 64 bits of the source integer.
87+
// MacDive has not been seen to emit 16-byte ints in practice.
8288
final byteCount = 1 << info;
8389
return BPlistInt(_readBigEndianInt(_bytes, offset + 1, byteCount));
8490

lib/features/universal_import/data/services/macdive_db_reader.dart

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ class MacDiveDbReader {
1818
/// have a `ZDIVE` table coincidentally.
1919
static const _requiredTables = ['ZDIVE', 'ZDIVESITE', 'ZGAS', 'ZTANKANDGAS'];
2020

21+
/// Synchronous companion to [isMacDiveDb] for callers that have
22+
/// already probed the SQLite table set (e.g. the format detector
23+
/// runs every DB-flavor check against one probe to avoid doubling
24+
/// temp-file I/O).
25+
static bool matchesTables(Set<String> tables) =>
26+
_requiredTables.every(tables.contains);
27+
2128
/// True when [bytes] is a SQLite database containing every table in
2229
/// [_requiredTables]. Returns false (doesn't throw) for non-SQLite
2330
/// inputs or unrelated schemas.
@@ -45,8 +52,10 @@ class MacDiveDbReader {
4552

4653
/// Reads every MacDive table relevant to the import pipeline and
4754
/// returns a fully populated [MacDiveRawLogbook]. The four tables in
48-
/// [_requiredTables] are validated up-front and must exist. For every
49-
/// other table (e.g. `ZCRITTER`, `ZBUDDY`, `ZGEARITEM`), a missing or
55+
/// [_requiredTables] are validated up-front (a [FormatException] is
56+
/// thrown if any are missing) so subsequent reads on them can rely
57+
/// on direct `db.select` without per-call guards. For every other
58+
/// table (e.g. `ZCRITTER`, `ZBUDDY`, `ZGEARITEM`), a missing or
5059
/// unpopulated table produces an empty collection rather than an
5160
/// error — some MacDive schema versions omit tables the user has
5261
/// never touched.
@@ -57,6 +66,7 @@ class MacDiveDbReader {
5766
await tmpFile.writeAsBytes(bytes);
5867
final db = sqlite3.open(tmpPath, mode: OpenMode.readOnly);
5968
try {
69+
_validateRequiredTables(db);
6070
final unitsPreference = _readUnitsPreference(db);
6171
final sites = _readSites(db);
6272
final buddies = _readBuddies(db);
@@ -365,8 +375,8 @@ class MacDiveDbReader {
365375
weight: _str(r['ZWEIGHT']),
366376
diveSiteFk: r['ZRELATIONSHIPDIVESITE'] as int?,
367377
certificationFk: r['ZRELATIONSHIPCERTIFICATION'] as int?,
368-
samplesBplist: _bytes(r['ZSAMPLES']),
369-
rawDataBplist: _bytes(r['ZRAWDATA']),
378+
samplesBlob: _bytes(r['ZSAMPLES']),
379+
rawDataBlob: _bytes(r['ZRAWDATA']),
370380
);
371381
}).toList();
372382
}
@@ -408,6 +418,24 @@ class MacDiveDbReader {
408418
}
409419
}
410420

421+
/// Confirms every table in [_requiredTables] exists in [db]. Called
422+
/// at the top of [readAll] so the readers below can count on these
423+
/// tables being present, and so that a missing-table failure surfaces
424+
/// as a clear error instead of a confusing "no such table" from a
425+
/// later query.
426+
static void _validateRequiredTables(Database db) {
427+
final rows = db.select("SELECT name FROM sqlite_master WHERE type='table'");
428+
final tables = rows.map<String>((r) => r['name'] as String).toSet();
429+
final missing = _requiredTables
430+
.where((t) => !tables.contains(t))
431+
.toList(growable: false);
432+
if (missing.isNotEmpty) {
433+
throw FormatException(
434+
'MacDive SQLite is missing required tables: ${missing.join(', ')}',
435+
);
436+
}
437+
}
438+
411439
// ---- utilities ----
412440

413441
static String _tmpPath() =>

lib/features/universal_import/data/services/macdive_dive_mapper.dart

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,32 @@ class MacDiveDiveMapper {
122122
return out;
123123
}
124124

125+
/// Returns the stable uddf-style id for a gear row. Prefers the MacDive
126+
/// UUID (guaranteed unique per gear item in Core Data); falls back to
127+
/// the name so older exports without UUIDs still link. Returns null
128+
/// when neither is present — callers should skip the gear entirely in
129+
/// that case.
130+
static String? _gearUddfId(MacDiveRawGear g) {
131+
if (g.uuid.isNotEmpty) return g.uuid;
132+
final name = g.name;
133+
if (name != null && name.isNotEmpty) return name;
134+
return null;
135+
}
136+
125137
static List<Map<String, dynamic>> _buildGearMaps(
126138
MacDiveRawLogbook logbook,
127139
MacDiveUnitConverter c,
128140
) {
129141
final out = <Map<String, dynamic>>[];
130142
for (final g in logbook.gearByPk.values) {
131-
final map = <String, dynamic>{};
132-
if (g.name != null) map['name'] = g.name;
143+
final name = g.name;
144+
// UddfEntityImporter._importEquipment skips items without a name.
145+
// Dropping them here avoids phantom entries in the review UI and
146+
// keeps equipmentIdMapping in sync with the emitted entities.
147+
if (name == null || name.isEmpty) continue;
148+
final uddfId = _gearUddfId(g);
149+
if (uddfId == null) continue;
150+
final map = <String, dynamic>{'name': name, 'uddfId': uddfId};
133151
if (g.manufacturer != null) map['brand'] = g.manufacturer;
134152
if (g.model != null) map['model'] = g.model;
135153
if (g.serial != null) map['serialNumber'] = g.serial;
@@ -254,13 +272,18 @@ class MacDiveDiveMapper {
254272
];
255273
if (buddyNames.isNotEmpty) map['unmatchedBuddyNames'] = buddyNames;
256274

257-
// Per-dive gear linkage (`logbook.diveToGearPks`) is read but not
258-
// emitted: the unified import payload currently has no dive→gear
259-
// reference convention (`equipmentRefs` isn't recognised by the
260-
// entity importer), and M2 behaves identically — gear is imported
261-
// as standalone equipment entities dedup'd across all dives. Wiring
262-
// dive↔gear links end-to-end is a cross-parser change tracked as
263-
// follow-up work.
275+
// Per-dive gear linkage via `equipmentRefs`. UddfEntityImporter
276+
// resolves each ref through `equipmentIdMapping[uddfId]` to find
277+
// the newly-created equipment row, so we emit the same uddfId
278+
// values produced by `_buildGearMaps` above (MacDive gear UUID,
279+
// with name fallback for older exports). Gear items skipped in
280+
// the equipment map are also omitted here.
281+
final gearPks = logbook.diveToGearPks[d.pk] ?? const <int>[];
282+
final equipmentRefs = <String>[
283+
for (final gpk in gearPks)
284+
if (logbook.gearByPk[gpk] case final g?) ?_gearUddfId(g),
285+
];
286+
if (equipmentRefs.isNotEmpty) map['equipmentRefs'] = equipmentRefs;
264287

265288
// Tags - emit names under `tagRefs`.
266289
final tagPks = logbook.diveToTagPks[d.pk] ?? const <int>[];

lib/features/universal_import/data/services/macdive_raw_types.dart

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,11 @@ class MacDiveRawDive {
5959
/// `ZSAMPLES` BLOB — MacDive's proprietary profile-sample format
6060
/// (NOT bplist; left on the model for future decode work, not
6161
/// used by M3).
62-
final Uint8List? samplesBplist;
62+
final Uint8List? samplesBlob;
6363

64-
/// `ZRAWDATA` BLOB — raw dive-computer sensor dump.
65-
final Uint8List? rawDataBplist;
64+
/// `ZRAWDATA` BLOB — raw dive-computer sensor dump (format varies by
65+
/// computer; not bplist).
66+
final Uint8List? rawDataBlob;
6667

6768
const MacDiveRawDive({
6869
required this.pk,
@@ -104,8 +105,8 @@ class MacDiveRawDive {
104105
this.weight,
105106
this.diveSiteFk,
106107
this.certificationFk,
107-
this.samplesBplist,
108-
this.rawDataBplist,
108+
this.samplesBlob,
109+
this.rawDataBlob,
109110
});
110111
}
111112

lib/features/universal_import/data/services/shearwater_db_reader.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,35 @@ LEFT JOIN log_data ld ON dd.DiveId = ld.log_id
115115
ORDER BY dd.DiveDate
116116
''';
117117

118+
/// Synchronous companion to [isShearwaterCloudDb] for callers that
119+
/// have already probed the SQLite table set (e.g. the format detector
120+
/// runs every DB-flavor check against one probe to avoid doubling
121+
/// temp-file I/O).
122+
static bool matchesTables(Set<String> tables) =>
123+
_requiredTables.every(tables.contains);
124+
125+
/// Writes [bytes] to a temp SQLite file, opens it read-only, and
126+
/// returns the set of table names. Returns an empty set (doesn't
127+
/// throw) for non-SQLite inputs. Shared by the format detector so
128+
/// multiple DB-flavor checks can run against one probe.
129+
static Future<Set<String>> probeSqliteTableNames(Uint8List bytes) async {
130+
final tempPath = _tempPath();
131+
final tempFile = File(tempPath);
132+
try {
133+
await tempFile.writeAsBytes(bytes);
134+
final db = sqlite3.open(tempPath, mode: OpenMode.readOnly);
135+
try {
136+
return _listTables(db);
137+
} finally {
138+
db.dispose();
139+
}
140+
} catch (_) {
141+
return const <String>{};
142+
} finally {
143+
_deleteTempFile(tempFile);
144+
}
145+
}
146+
118147
/// Returns true if the given bytes represent a Shearwater Cloud database.
119148
///
120149
/// Writes the bytes to a temporary file and opens it as SQLite. Checks

lib/features/universal_import/presentation/providers/universal_import_providers.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,18 @@ class UniversalImportNotifier extends StateNotifier<UniversalImportState> {
8383
var detection = detector.detect(bytes);
8484

8585
if (detection.format == ImportFormat.sqlite) {
86-
final isShearwater = await ShearwaterDbReader.isShearwaterCloudDb(bytes);
87-
if (isShearwater) {
86+
// Probe the SQLite table set once and reuse for each DB flavor
87+
// check. Without this, every flavor would re-write the full byte
88+
// array to its own temp file and re-open sqlite — wasteful for
89+
// large dive databases on mobile/low-end devices.
90+
final tables = await ShearwaterDbReader.probeSqliteTableNames(bytes);
91+
if (ShearwaterDbReader.matchesTables(tables)) {
8892
detection = const DetectionResult(
8993
format: ImportFormat.shearwaterDb,
9094
sourceApp: SourceApp.shearwater,
9195
confidence: 0.95,
9296
);
93-
} else if (await MacDiveDbReader.isMacDiveDb(bytes)) {
97+
} else if (MacDiveDbReader.matchesTables(tables)) {
9498
detection = const DetectionResult(
9599
format: ImportFormat.macdiveSqlite,
96100
sourceApp: SourceApp.macdive,

test/features/universal_import/data/parsers/macdive_sqlite_parser_test.dart

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,33 @@ void main() {
6868
expect(payload.isEmpty, isTrue);
6969
expect(payload.warnings.first.severity, ImportWarningSeverity.error);
7070
});
71+
72+
test(
73+
'gear entities carry a stable uddfId matching the gear UUID',
74+
() async {
75+
final payload = await const MacDiveSqliteParser().parse(validBytes);
76+
final equipment = payload.entitiesOf(ImportEntityType.equipment);
77+
expect(equipment, hasLength(1));
78+
final gear = equipment.single;
79+
expect(gear['name'], 'Hydros Pro');
80+
expect(gear['uddfId'], 'gear-uuid-1');
81+
expect(gear['sourceUuid'], 'gear-uuid-1');
82+
},
83+
);
84+
85+
test(
86+
'dives emit equipmentRefs pointing at gear uddfIds, enabling linking',
87+
() async {
88+
final payload = await const MacDiveSqliteParser().parse(validBytes);
89+
final dives = payload.entitiesOf(ImportEntityType.dives);
90+
// Dive 1 in the synthetic fixture has the only dive↔gear junction.
91+
final withGear = dives.where(
92+
(d) => (d['equipmentRefs'] as List?)?.isNotEmpty ?? false,
93+
);
94+
expect(withGear, hasLength(1));
95+
final refs = withGear.single['equipmentRefs'] as List;
96+
expect(refs, ['gear-uuid-1']);
97+
},
98+
);
7199
});
72100
}

test/features/universal_import/data/parsers/macdive_sqlite_real_sample_test.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,38 @@ void main() {
124124
expect(withTanks, isNotEmpty);
125125
});
126126

127+
test('at least one dive has equipmentRefs linking to imported gear', () async {
128+
final payload = await const MacDiveSqliteParser().parse(bytes);
129+
final dives = payload.entitiesOf(ImportEntityType.dives);
130+
final withGear = dives.where(
131+
(d) => (d['equipmentRefs'] as List?)?.isNotEmpty ?? false,
132+
);
133+
expect(
134+
withGear,
135+
isNotEmpty,
136+
reason:
137+
'MacDive SQLite has dive↔gear junctions (Z_5RELATIONSHIPGEARITEMS); '
138+
'at least one dive in the 540-dive sample should carry equipmentRefs',
139+
);
140+
141+
final equipment = payload.entitiesOf(ImportEntityType.equipment);
142+
final uddfIds = equipment
143+
.map((g) => g['uddfId'] as String?)
144+
.whereType<String>()
145+
.toSet();
146+
for (final dive in withGear) {
147+
for (final ref in (dive['equipmentRefs'] as List).cast<String>()) {
148+
expect(
149+
uddfIds,
150+
contains(ref),
151+
reason:
152+
'every equipmentRef must resolve to an emitted gear uddfId so '
153+
'UddfEntityImporter.equipmentIdMapping picks it up',
154+
);
155+
}
156+
}
157+
});
158+
127159
test(
128160
'profile is always empty (ZSAMPLES proprietary, not decoded)',
129161
() async {

0 commit comments

Comments
 (0)