Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0f0bba7
plan: narrow M1 Task 1 source_uuid to dive_data_sources only
ericgriffin Apr 21, 2026
25cf0fa
feat(db): add source_uuid to dive_data_sources for cross-format impor…
ericgriffin Apr 21, 2026
edcab43
test(db): rename and expand v70 migration test to match house convention
ericgriffin Apr 21, 2026
5beade5
feat(uddf): add LinkRefIndex for ref-kind disambiguation
ericgriffin Apr 21, 2026
321482d
plan: skip M1 Task 3 after finding non-existent bug
ericgriffin Apr 21, 2026
6c7da41
test(uddf): lock down <infinity/> surface interval handling
ericgriffin Apr 21, 2026
5174024
feat(uddf): extract MacDive extended dive fields (weather, boat, oper…
ericgriffin Apr 21, 2026
0bfa13b
feat(uddf): extract site waterType / bodyOfWater / difficulty / flag …
ericgriffin Apr 21, 2026
727d32c
fix(uddf): ensure equipmentused refs from both before/after sections …
ericgriffin Apr 21, 2026
e68f922
fix(uddf): record gasMixRef on samples carrying <switchmix>
ericgriffin Apr 21, 2026
f22dd8d
feat(import): persist UDDF <dive id> as dive_data_sources.source_uuid
ericgriffin Apr 21, 2026
ca47edc
test(import): replace parser-only test with real integration test for…
ericgriffin Apr 21, 2026
88388da
test(uddf): MacDive real-sample regression (gated)
ericgriffin Apr 21, 2026
4346702
fix(uddf): extract equipment from standard <diver><owner><equipment> …
ericgriffin Apr 21, 2026
c385af2
refactor(uddf): drop low-value extractions and unused LinkRefIndex
ericgriffin Apr 21, 2026
ef70adb
feat(import): persist MacDive dive/site metadata to DB
ericgriffin Apr 21, 2026
d015016
chore: changelog and plan update for M1 completion
ericgriffin Apr 21, 2026
7a38724
fix(import): use Value.absent() for missing companion fields
ericgriffin Apr 22, 2026
745e50c
test(uddf): make MacDive real-sample suite portable and skip-safe
ericgriffin Apr 22, 2026
7e95350
chore(test): regenerate mocks for applyImportedMetadata
ericgriffin Apr 23, 2026
bda04de
refactor(import): drop dive_number_of_day column; it's derivable
ericgriffin Apr 23, 2026
90b3c44
docs(plan): reflect dive_number_of_day removal in Milestone 1 plan
ericgriffin Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,38 @@
All notable changes to Submersion are documented in this file.


## Unreleased

### Added

- MacDive UDDF imports now capture substantially richer dive data: boat
name and captain, dive operator, surface conditions, weather (stored
in the existing weather description field), plus site water type, body
of water, and difficulty rating.
- Cross-format import deduplication: stable per-dive UUIDs from MacDive,
Shearwater Cloud, Subsurface SSRF, and generic UDDF are now preserved on
the `dive_data_sources` sidecar. Re-importing the same dives in a
different format no longer creates duplicates.

### Fixed

- MacDive UDDF: equipment / gear now imports correctly. The parser
previously only scanned Submersion's private equipment extension,
missing the standard UDDF gear location (`<diver><owner><equipment>`)
where MacDive and other compliant exporters place their inventory.
- MacDive UDDF: `<equipmentused><link ref>` is now captured from both
`<informationbeforedive>` and `<informationafterdive>`.
- MacDive UDDF: `<surfaceintervalbeforedive><infinity/></…>` is now
explicitly handled as "no prior dive" rather than relying on silent
int-parse failure.

### Known limitations (to be addressed in a follow-up)

- Gas switch markers (`<switchmix ref>`) in MacDive dive profiles are
parsed but not yet persisted to the profile samples table. A future
milestone will wire them through, likely via the dive-events table.


## 1.4.6 (2026-04-22)

### Bug Fixes
Expand Down
4 changes: 4 additions & 0 deletions dart_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ tags:
# Run explicitly: flutter test --run-skipped --tags performance
# Or: flutter test --run-skipped test/performance/
skip: "Slow performance tests; run with --run-skipped --tags performance"
real-data:
# Real-data tests use actual user exports and may take time to run.
# Run explicitly: flutter test --run-skipped --tags real-data
skip: "Real-data regression tests; run with --run-skipped --tags real-data"
125 changes: 80 additions & 45 deletions docs/superpowers/plans/2026-04-21-macdive-uddf-gap-fill.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,49 @@

---

## Milestone 1 Status — COMPLETE

- All 12 tasks landed or explicitly skipped (Task 3 proven non-bug; see Task 3 section).
- Schema bumped v69 → v70 (source_uuid on dive_data_sources) → v71
(6 new dive + site metadata columns: boat_name, boat_captain,
dive_operator, surface_conditions on dives; water_type, body_of_water
on dive_sites). Task 8 and Task 10's snippets below show an earlier
plan that also added `dive_number_of_day`; see "Post-completion
revision" note directly below.
- Cross-format import dedup now works via `dive_data_sources.source_uuid`.
- Parser-to-DB gap closed for MacDive rich fields. `weather` now lands
on the existing weather_description column. difficulty continues to
flow through the DiveSite entity path.
- Dropped from scope: personalMode, altitudeMode, signature, site flag
(niche / redundant). LinkRefKind/LinkRefIndex (Task 2) also removed
after Task 3 investigation showed the bug they targeted didn't exist.
- Known limitation: profile `gasMixRef` (from `<switchmix ref>`) is
parsed but not yet persisted to the profile samples table — deferred
to a future milestone, likely via dive-events.
- Real-sample regression test passes (gated behind `@Tags(['real-data'])`).

### Post-completion revision: dive_number_of_day is derived, not stored

Originally this milestone planned a `dive_number_of_day` INT column on
`dives`, populated from UDDF `<divenumberofday>`. After landing,
review found the value is trivially derivable from `diveDateTime`
(`ROW_NUMBER() OVER (PARTITION BY DATE(diveDateTime) ORDER BY
diveDateTime)`) and has no current reader in the app. Storing it would
also desync the moment a diver manually adds a dive between two
imported dives. The column, its v71 ALTER, parser extraction,
`IncomingDiveData` field, and importer write path were all removed.
Task 8's "Add fields to the domain model" snippet and Task 10's DB
write snippet in this document are the pre-revision plan; they are
kept for historical context. `<divenumberofday>` is now ignored on
import and computed on demand if/when the UI needs it.

---

## File Structure

| File | Role | New / Modified |
|---|---|---|
| `lib/core/database/database.dart` | Add `sourceUuid` columns to `Dives`, `DiveSites`, `Buddies`, `Gear`, `Tags`, `Certifications`, `Species`, `DiveTypes`, `Trips`. Bump schema version and add migration step. | Modified |
| `lib/core/database/database.dart` | Add single `sourceUuid TEXT NULL` column to the existing `DiveDataSources` 1:N sidecar table. That's where per-source dive identity already lives (alongside `rawFingerprint`, `sourceFormat`, `sourceFileName`, `computerSerial`). Bump schema version 69 → 70 and add migration step. No changes to `dives`, `dive_sites`, `buddies`, or any other top-level entity tables. | Modified |
| `lib/core/services/export/uddf/dialects/macdive_dialect.dart` | Add `<infinity/>` normalization for surface interval; preserve idempotency for all existing rewrites. | Modified |
| `lib/core/services/export/uddf/uddf_import_parsers.dart` | Add `resolveLinkRef(XmlElement, Map<String, LinkRefKind>)` helper that resolves a `<link ref="…"/>` against a pre-built index of top-level IDs and returns the entity kind. | Modified |
| `lib/core/services/export/uddf/uddf_full_import_service.dart` | Build ID→kind index once per import; use the helper inside `informationbeforedive` / `equipmentused` parsing; extract newly-supported dive and site fields; store each entity's source UUID on its dive/site/buddy/etc. map under `sourceUuid`. | Modified |
Expand All @@ -29,7 +67,9 @@

---

## Task 1: Schema migration — add `source_uuid` columns
## Task 1: Schema migration — add `source_uuid` column to `dive_data_sources`

**Design rationale:** The project already has a 1:N `dive_data_sources` sidecar table carrying per-source dive metadata: `raw_fingerprint` (libdivecomputer), `source_format`, `source_file_name`, `computer_serial`, etc. This is where "where this dive came from" already lives. Adding `source_uuid` here (rather than on `dives` or on every top-level table) keeps the schema change minimal (one column, one table) and aligns with the existing architecture. libdivecomputer continues to use its existing `raw_fingerprint` BLOB — different mechanism, same role. The new `source_uuid` column captures the string/UUID identifiers that Shearwater Cloud (`DiveId`), MacDive (UDDF `<dive id>`, XML `<identifier>`, SQLite `ZUUID`), Subsurface SSRF, and generic UDDF all provide.

**Files:**
- Modify: `lib/core/database/database.dart`
Expand All @@ -40,37 +80,22 @@
Create `test/core/database/source_uuid_migration_test.dart`:

```dart
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:submersion/core/database/database.dart';

void main() {
test('fresh database has source_uuid column on dives and related tables',
() async {
test('fresh database has source_uuid column on dive_data_sources', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final tables = [
'dives',
'dive_sites',
'buddies',
'gear',
'tags',
'certifications',
'species',
'dive_types',
'trips',
];
for (final table in tables) {
final cols = await db
.customSelect("PRAGMA table_info('$table')")
.get();
final names = cols.map((r) => r.data['name'] as String).toSet();
expect(
names.contains('source_uuid'),
isTrue,
reason: 'table $table must have source_uuid column',
);
}
final cols = await db
.customSelect("PRAGMA table_info('dive_data_sources')")
.get();
final names = cols.map((r) => r.data['name'] as String).toSet();
expect(
names.contains('source_uuid'),
isTrue,
reason: 'dive_data_sources must have source_uuid column',
);
await db.close();
});
}
Expand All @@ -79,33 +104,32 @@ void main() {
- [ ] **Step 2: Run test to verify it fails**

Run: `flutter test test/core/database/source_uuid_migration_test.dart`
Expected: FAIL — `table dives must have source_uuid column`.
Expected: FAIL — `dive_data_sources must have source_uuid column`.

- [ ] **Step 3: Add columns to each table definition in `database.dart`**
- [ ] **Step 3: Add the column to `DiveDataSources`**

For each of the nine tables, add:
In `lib/core/database/database.dart`, locate `class DiveDataSources extends Table` (around line 953). Add a nullable text column near the other per-source identity columns (e.g. immediately after `rawFingerprint`):

```dart
TextColumn get sourceUuid => text().nullable()();
TextColumn get sourceUuid => text().nullable()();
```

Place it at the end of each table class before the `override Set<Column> get primaryKey => …` (if any).

- [ ] **Step 4: Bump schema version and add migration step**

Open `lib/core/database/database.dart`, find the `schemaVersion` getter, bump it by one (e.g. `49 → 50`). In the `MigrationStrategy.onUpgrade`, add a new case matching the new version that runs:
Find `currentSchemaVersion` (around line 1329) and bump from `69` to `70`.

Locate the existing `dive_data_sources` ALTER migration pattern (around lines 3083-3108 — where `raw_fingerprint`, `descriptor_vendor`, `descriptor_product`, etc. are added with `if (!existing.contains('X')) { await customStatement('ALTER TABLE dive_data_sources ADD COLUMN X Y') }`). Append to the same block (inside the same `if (from < N)` guard or in a new `if (from < 70)` block, matching the file's existing idiom):

```dart
if (from < 50) {
for (final table in const [
'dives', 'dive_sites', 'buddies', 'gear', 'tags',
'certifications', 'species', 'dive_types', 'trips',
]) {
await m.customStatement('ALTER TABLE $table ADD COLUMN source_uuid TEXT;');
}
if (!existing.contains('source_uuid')) {
await customStatement(
'ALTER TABLE dive_data_sources ADD COLUMN source_uuid TEXT',
);
}
```

Grep the existing migration code for the exact `existing` variable name and reuse it; don't duplicate the `PRAGMA table_info` introspection if it's already in scope.

- [ ] **Step 5: Regenerate drift outputs and run the test**

Run:
Expand All @@ -118,9 +142,8 @@ Expected: PASS.
- [ ] **Step 6: Commit**

```bash
git add lib/core/database/database.dart test/core/database/source_uuid_migration_test.dart
git add lib/core/database/database.g.dart # generated
git commit -m "feat(db): add source_uuid columns for cross-format import dedup"
git add lib/core/database/database.dart lib/core/database/database.g.dart test/core/database/source_uuid_migration_test.dart
git commit -m "feat(db): add source_uuid to dive_data_sources for cross-format import dedup"
```

---
Expand Down Expand Up @@ -202,7 +225,19 @@ git commit -m "feat(uddf): add LinkRefIndex for ref-kind disambiguation"

---

## Task 3: Build and use the ID index in `UddfFullImportService`
## Task 3: Build and use the ID index in `UddfFullImportService` — **SKIPPED**

**Investigation outcome:** The plan's premise (that the current parser assumes positional order for `<link ref>` children in `<informationbeforedive>`) turned out to be wrong. The existing `_parseUddfDive` at `uddf_full_import_service.dart:1173-1211` already classifies each link by looking up its `ref` attribute in pre-built entity maps (`sites`, `buddies`, `decoModels`, `diveComputers`). The prescribed failing test passes against unmodified code.

**What the real gap is (deferred):** `_parseFullDive` at lines 557-571 uses string-prefix matching (`ref.startsWith('trip_')`, `'center_'`, `'course_'`) to classify trip/center/course refs. This WOULD fail for MacDive-style UUIDs — but MacDive UDDF (per the user's 29MB sample) does not emit `<trip>` / `<divecenter>` / `<course>` references at all. For Milestone 1 scope (MacDive UDDF), this codepath is not exercised. Revisit in Milestone 3 (MacDive SQLite) if trip/center refs start showing up.

**LinkRefKind / LinkRefIndex from Task 2 are retained** — they cost ~22 lines + 2 tests and may be useful for the Milestone 3 SQLite work where cross-entity UUID resolution matters.
Comment thread
ericgriffin marked this conversation as resolved.

**Status:** Task 3 marked as SKIPPED in the task tracker. No code change for this task.

---

## Task 3 (ORIGINAL, for reference) — not executed

**Files:**
- Modify: `lib/core/services/export/uddf/uddf_full_import_service.dart`
Expand Down
82 changes: 81 additions & 1 deletion lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ class Dives extends Table {
text().withDefault(const Constant('recreational'))();
TextColumn get buddy => text().nullable()();
TextColumn get diveMaster => text().nullable()();
// MacDive import fields — common dive metadata
TextColumn get boatName => text().nullable()();
TextColumn get boatCaptain => text().nullable()();
TextColumn get diveOperator => text().nullable()();
TextColumn get surfaceConditions => text().nullable()();
Comment thread
ericgriffin marked this conversation as resolved.
TextColumn get notes => text().withDefault(const Constant(''))();
TextColumn get siteId => text().nullable().references(DiveSites, #id)();
IntColumn get rating => integer().nullable()();
Expand Down Expand Up @@ -300,6 +305,9 @@ class DiveSites extends Table {
RealColumn get maxDepth => real().nullable()(); // Deepest point
TextColumn get difficulty =>
text().nullable()(); // Beginner, Intermediate, Advanced, Technical
// MacDive site metadata
TextColumn get waterType => text().nullable()();
TextColumn get bodyOfWater => text().nullable()();
TextColumn get country => text().nullable()();
TextColumn get region => text().nullable()();
RealColumn get rating => real().nullable()();
Expand Down Expand Up @@ -983,6 +991,7 @@ class DiveDataSources extends Table {
DateTimeColumn get createdAt => dateTime()();
BlobColumn get rawData => blob().nullable()();
BlobColumn get rawFingerprint => blob().nullable()();
TextColumn get sourceUuid => text().nullable()();
TextColumn get descriptorVendor => text().nullable()();
TextColumn get descriptorProduct => text().nullable()();
IntColumn get descriptorModel => integer().nullable()();
Expand Down Expand Up @@ -1326,7 +1335,7 @@ class AppDatabase extends _$AppDatabase {

/// The current schema version as a static constant so that pre-open checks
/// (e.g. version-mismatch guard) can reference it without an instance.
static const int currentSchemaVersion = 69;
static const int currentSchemaVersion = 71;

/// Every schema version that has a migration block in onUpgrade.
/// Used to calculate progress step counts. When adding a new migration,
Expand Down Expand Up @@ -1399,6 +1408,8 @@ class AppDatabase extends _$AppDatabase {
67,
68,
69,
70,
71,
];

/// Returns the number of migration steps that will execute when upgrading
Expand Down Expand Up @@ -3244,6 +3255,75 @@ class AppDatabase extends _$AppDatabase {
}
}
if (from < 69) await reportProgress();
if (from < 70) {
// Migration 70: add source_uuid to dive_data_sources for
// cross-format import deduplication (MacDive UUID, Shearwater
// DiveId, Subsurface SSRF id, generic UDDF dive id).
// libdivecomputer continues to use raw_fingerprint.
// Guard: dive_data_sources may not exist in older migration tests.
final cols = await customSelect(
"PRAGMA table_info('dive_data_sources')",
).get();
if (cols.isNotEmpty) {
final existing = cols.map((c) => c.read<String>('name')).toSet();
if (!existing.contains('source_uuid')) {
await customStatement(
'ALTER TABLE dive_data_sources ADD COLUMN source_uuid TEXT',
);
}
}
}
if (from < 70) await reportProgress();
if (from < 71) {
// Migration 71: add MacDive dive + site metadata fields.
final divesCols = await customSelect(
"PRAGMA table_info('dives')",
).get();
final divesExisting = divesCols
.map((r) => r.data['name'] as String)
.toSet();
if (divesCols.isNotEmpty) {
if (!divesExisting.contains('boat_name')) {
await customStatement(
'ALTER TABLE dives ADD COLUMN boat_name TEXT',
);
}
if (!divesExisting.contains('boat_captain')) {
await customStatement(
'ALTER TABLE dives ADD COLUMN boat_captain TEXT',
);
}
if (!divesExisting.contains('dive_operator')) {
await customStatement(
'ALTER TABLE dives ADD COLUMN dive_operator TEXT',
);
}
if (!divesExisting.contains('surface_conditions')) {
await customStatement(
'ALTER TABLE dives ADD COLUMN surface_conditions TEXT',
);
}
}
final sitesCols = await customSelect(
"PRAGMA table_info('dive_sites')",
).get();
final sitesExisting = sitesCols
.map((r) => r.data['name'] as String)
.toSet();
if (sitesCols.isNotEmpty) {
if (!sitesExisting.contains('water_type')) {
await customStatement(
'ALTER TABLE dive_sites ADD COLUMN water_type TEXT',
);
}
if (!sitesExisting.contains('body_of_water')) {
await customStatement(
'ALTER TABLE dive_sites ADD COLUMN body_of_water TEXT',
);
}
}
}
if (from < 71) await reportProgress();
},
beforeOpen: (details) async {
// Enable foreign keys
Expand Down
18 changes: 18 additions & 0 deletions lib/core/domain/models/incoming_dive_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class IncomingDiveData {
final String? computerSerial;
final List<DiveProfilePoint> profile;
final String? siteName;
final String? weather;
final String? surfaceConditions;
final String? boatName;
final String? boatCaptain;
final String? diveOperator;
final String? sourceUuid;

const IncomingDiveData({
this.startTime,
Expand All @@ -30,6 +36,12 @@ class IncomingDiveData {
this.computerSerial,
this.profile = const [],
this.siteName,
this.weather,
this.surfaceConditions,
this.boatName,
this.boatCaptain,
this.diveOperator,
this.sourceUuid,
});

/// Create from a [DownloadedDive] (dive computer download flow).
Expand Down Expand Up @@ -86,6 +98,12 @@ class IncomingDiveData {
computerSerial: data['diveComputerSerial'] as String?,
siteName: data['siteName'] as String?,
profile: profile,
weather: data['weather'] as String?,
surfaceConditions: data['surfaceConditions'] as String?,
boatName: data['boatName'] as String?,
boatCaptain: data['boatCaptain'] as String?,
diveOperator: data['diveOperator'] as String?,
sourceUuid: data['sourceUuid'] as String?,
);
}
}
Loading
Loading