feat(inventory): CSV/JSON export and JSON import with LWW reconciliation
Interchange export/import for Phase 1 (data-model §7): - JSON: canonical, versioned envelope (formatVersion 1) with all sync metadata verbatim, photos embedded as base64, tombstones excluded. Species are re-resolved on import by scientific name (catalog ids are per-install). Reader tolerates unknown fields/enum values (§5.2) and rejects newer format versions with a clear error. - CSV: export-only spreadsheet flatten, one row per lot, RFC 4180 escaping, never any photo bytes. - Import merges by UUIDv7 id in one transaction: insert-if-unknown preserving original stamps, last-writer-wins by packed HLC for known mutable rows, append-only movements; afterwards the local clock receiveEvent()s the newest imported stamp so it never runs behind. - Settings gains a Backup section (export CSV/JSON, import JSON with confirmation); file dialogs behind a FileService interface backed by file_picker (MIT). - Tests: codec round-trip and tolerance, reconciler LWW, repository export→import round-trip (fresh DB, idempotent re-import, newer-local wins, clock monotonicity, species re-resolution), backup widget flows.
This commit is contained in:
parent
136ed701a7
commit
2812c99280
25 changed files with 2207 additions and 7 deletions
42
apps/app_seeds/lib/data/export_import/import_reconciler.dart
Normal file
42
apps/app_seeds/lib/data/export_import/import_reconciler.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
|
||||
/// What to do with one incoming row when importing (data-model §7).
|
||||
enum ReconcileAction { insert, update, skip }
|
||||
|
||||
/// Pure reconciliation rules for the JSON import. No database access — the
|
||||
/// repository queries existing rows and applies the returned action.
|
||||
class ImportReconciler {
|
||||
const ImportReconciler();
|
||||
|
||||
/// Last-writer-wins for mutable rows, keyed by the packed HLC `updatedAt`
|
||||
/// (data-model §4): unknown id → insert; known id → the incoming row wins
|
||||
/// only if its stamp is strictly newer, otherwise skip. Re-importing the
|
||||
/// same file is therefore a no-op (idempotent).
|
||||
ReconcileAction reconcileMutable({
|
||||
required String? existingUpdatedAt,
|
||||
required String incomingUpdatedAt,
|
||||
}) {
|
||||
if (existingUpdatedAt == null) return ReconcileAction.insert;
|
||||
final incoming = Hlc.parse(incomingUpdatedAt);
|
||||
final existing = Hlc.parse(existingUpdatedAt);
|
||||
return incoming.compareTo(existing) > 0
|
||||
? ReconcileAction.update
|
||||
: ReconcileAction.skip;
|
||||
}
|
||||
|
||||
/// Append-only rows (movements): insert when unknown, never update.
|
||||
ReconcileAction reconcileAppendOnly({required bool exists}) =>
|
||||
exists ? ReconcileAction.skip : ReconcileAction.insert;
|
||||
|
||||
/// The newest stamp among [packedHlcs], or null when empty. After an import
|
||||
/// the local clock must `receiveEvent` this so it never falls behind the
|
||||
/// imported rows (the next local write must out-stamp all of them).
|
||||
Hlc? maxHlc(Iterable<String> packedHlcs) {
|
||||
Hlc? max;
|
||||
for (final packed in packedHlcs) {
|
||||
final hlc = Hlc.parse(packed);
|
||||
if (max == null || hlc.compareTo(max) > 0) max = hlc;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
}
|
||||
144
apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart
Normal file
144
apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import '../../db/database.dart';
|
||||
import '../../db/enums.dart';
|
||||
import 'inventory_snapshot.dart';
|
||||
|
||||
/// Flattens an [InventorySnapshot] into a spreadsheet-friendly CSV — one row
|
||||
/// per lot, one row for a variety without lots. Export-only: the JSON format
|
||||
/// is the canonical one for import (data-model §7). Never includes photo
|
||||
/// bytes. Pure serialization — no I/O, no database.
|
||||
class InventoryCsvCodec {
|
||||
const InventoryCsvCodec();
|
||||
|
||||
static const List<String> _header = [
|
||||
'variety_id',
|
||||
'variety_label',
|
||||
'category',
|
||||
'species_scientific_name',
|
||||
'cultivar_name',
|
||||
'variety_notes',
|
||||
'vernacular_names',
|
||||
'external_links',
|
||||
'lot_id',
|
||||
'lot_type',
|
||||
'harvest_year',
|
||||
'harvest_month',
|
||||
'quantity_kind',
|
||||
'quantity_precise',
|
||||
'quantity_label',
|
||||
'presentation',
|
||||
'storage_location',
|
||||
'offer_status',
|
||||
'latest_germination_rate',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
String encode(InventorySnapshot snapshot) {
|
||||
final lotsByVariety = <String, List<Lot>>{};
|
||||
for (final lot in snapshot.lots) {
|
||||
lotsByVariety.putIfAbsent(lot.varietyId, () => []).add(lot);
|
||||
}
|
||||
final namesByVariety = <String, List<String>>{};
|
||||
for (final n in snapshot.vernacularNames) {
|
||||
namesByVariety
|
||||
.putIfAbsent(n.varietyId, () => [])
|
||||
.add(n.language == null ? n.name : '${n.name} (${n.language})');
|
||||
}
|
||||
final linksByVariety = <String, List<String>>{};
|
||||
for (final e in snapshot.externalLinks) {
|
||||
if (e.parentType != ParentType.variety) continue;
|
||||
linksByVariety
|
||||
.putIfAbsent(e.parentId, () => [])
|
||||
.add(e.title == null ? e.url : '${e.title}|${e.url}');
|
||||
}
|
||||
final latestRateByLot = _latestGerminationRates(snapshot.germinationTests);
|
||||
|
||||
final lines = <String>[_header.map(_escape).join(',')];
|
||||
for (final v in snapshot.varieties) {
|
||||
final varietyCells = [
|
||||
v.id,
|
||||
v.label,
|
||||
v.category ?? '',
|
||||
snapshot.speciesNamesById[v.speciesId] ?? '',
|
||||
v.cultivarName ?? '',
|
||||
v.notes ?? '',
|
||||
(namesByVariety[v.id] ?? const []).join('; '),
|
||||
(linksByVariety[v.id] ?? const []).join('; '),
|
||||
];
|
||||
final lots = lotsByVariety[v.id] ?? const <Lot>[];
|
||||
if (lots.isEmpty) {
|
||||
lines.add(
|
||||
[
|
||||
...varietyCells,
|
||||
...List.filled(11, ''),
|
||||
_isoDate(v.createdAt),
|
||||
_hlcMillisIso(v.updatedAt),
|
||||
].map(_escape).join(','),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (final l in lots) {
|
||||
lines.add(
|
||||
[
|
||||
...varietyCells,
|
||||
l.id,
|
||||
l.type.name,
|
||||
l.harvestYear?.toString() ?? '',
|
||||
l.harvestMonth?.toString() ?? '',
|
||||
l.quantityKind ?? '',
|
||||
l.quantityPrecise?.toString() ?? '',
|
||||
l.quantityLabel ?? '',
|
||||
l.presentation?.name ?? '',
|
||||
l.storageLocation ?? '',
|
||||
l.offerStatus.name,
|
||||
latestRateByLot[l.id]?.toStringAsFixed(2) ?? '',
|
||||
_isoDate(l.createdAt),
|
||||
_hlcMillisIso(l.updatedAt),
|
||||
].map(_escape).join(','),
|
||||
);
|
||||
}
|
||||
}
|
||||
return '${lines.join('\r\n')}\r\n';
|
||||
}
|
||||
|
||||
/// lotId → latest germination rate (0..1), from the most recent test that
|
||||
/// has a computable rate.
|
||||
Map<String, double> _latestGerminationRates(List<GerminationTest> tests) {
|
||||
final latest = <String, GerminationTest>{};
|
||||
for (final t in tests) {
|
||||
final sample = t.sampleSize;
|
||||
if (sample == null || sample <= 0 || t.germinatedCount == null) continue;
|
||||
final current = latest[t.lotId];
|
||||
if (current == null || (t.testedOn ?? 0) > (current.testedOn ?? 0)) {
|
||||
latest[t.lotId] = t;
|
||||
}
|
||||
}
|
||||
return latest.map(
|
||||
(lotId, t) => MapEntry(lotId, t.germinatedCount! / t.sampleSize!),
|
||||
);
|
||||
}
|
||||
|
||||
String _isoDate(int millisSinceEpoch) => DateTime.fromMillisecondsSinceEpoch(
|
||||
millisSinceEpoch,
|
||||
isUtc: true,
|
||||
).toIso8601String();
|
||||
|
||||
/// Renders the physical-time part of a packed HLC as an ISO date; the raw
|
||||
/// stamp stays in the JSON export, the CSV favours readability.
|
||||
String _hlcMillisIso(String packedHlc) {
|
||||
final millis = int.tryParse(packedHlc.split(':').first);
|
||||
return millis == null ? '' : _isoDate(millis);
|
||||
}
|
||||
|
||||
/// RFC 4180: quote fields containing separators/quotes/newlines, doubling
|
||||
/// embedded quotes.
|
||||
String _escape(String cell) {
|
||||
if (!cell.contains(',') &&
|
||||
!cell.contains('"') &&
|
||||
!cell.contains('\n') &&
|
||||
!cell.contains('\r')) {
|
||||
return cell;
|
||||
}
|
||||
return '"${cell.replaceAll('"', '""')}"';
|
||||
}
|
||||
}
|
||||
411
apps/app_seeds/lib/data/export_import/inventory_json_codec.dart
Normal file
411
apps/app_seeds/lib/data/export_import/inventory_json_codec.dart
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' show Uint8List;
|
||||
|
||||
import '../../db/database.dart';
|
||||
import '../../db/enums.dart';
|
||||
import 'inventory_snapshot.dart';
|
||||
|
||||
/// Encodes/decodes an [InventorySnapshot] to/from the versioned interchange
|
||||
/// JSON (data-model §7). Pure serialization — no I/O, no database.
|
||||
///
|
||||
/// Reader tolerance follows data-model §5.2: unknown JSON fields are ignored;
|
||||
/// unknown enum values fall back to a safe default, or drop the row when no
|
||||
/// default makes sense (e.g. an unknown movement type). Photo/doc bytes are
|
||||
/// embedded as base64 — the JSON export is the complete interchange backup.
|
||||
///
|
||||
/// Throws [FormatException] on malformed input or a `formatVersion` newer
|
||||
/// than [inventoryFormatVersion].
|
||||
class InventoryJsonCodec {
|
||||
const InventoryJsonCodec();
|
||||
|
||||
String encode(InventorySnapshot snapshot) {
|
||||
return const JsonEncoder.withIndent(' ').convert({
|
||||
'formatVersion': inventoryFormatVersion,
|
||||
'schemaVersion': AppDatabase.currentSchemaVersion,
|
||||
'appId': inventoryAppId,
|
||||
'varieties': [
|
||||
for (final v in snapshot.varieties)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: v.id,
|
||||
createdAt: v.createdAt,
|
||||
updatedAt: v.updatedAt,
|
||||
lastAuthor: v.lastAuthor,
|
||||
schemaRowVersion: v.schemaRowVersion,
|
||||
),
|
||||
'label': v.label,
|
||||
'speciesId': v.speciesId,
|
||||
// Species ids are per-install; the scientific name is what an
|
||||
// importer can re-resolve against its own bundled catalog.
|
||||
'speciesScientificName': snapshot.speciesNamesById[v.speciesId],
|
||||
'cultivarName': v.cultivarName,
|
||||
'category': v.category,
|
||||
'notes': v.notes,
|
||||
},
|
||||
],
|
||||
'lots': [
|
||||
for (final l in snapshot.lots)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: l.id,
|
||||
createdAt: l.createdAt,
|
||||
updatedAt: l.updatedAt,
|
||||
lastAuthor: l.lastAuthor,
|
||||
schemaRowVersion: l.schemaRowVersion,
|
||||
),
|
||||
'varietyId': l.varietyId,
|
||||
'type': l.type.name,
|
||||
'harvestYear': l.harvestYear,
|
||||
'harvestMonth': l.harvestMonth,
|
||||
'quantityKind': l.quantityKind,
|
||||
'quantityPrecise': l.quantityPrecise,
|
||||
'quantityLabel': l.quantityLabel,
|
||||
'presentation': l.presentation?.name,
|
||||
'storageLocation': l.storageLocation,
|
||||
'offerStatus': l.offerStatus.name,
|
||||
'seedbankId': l.seedbankId,
|
||||
},
|
||||
],
|
||||
'vernacularNames': [
|
||||
for (final n in snapshot.vernacularNames)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: n.id,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
lastAuthor: n.lastAuthor,
|
||||
schemaRowVersion: n.schemaRowVersion,
|
||||
),
|
||||
'varietyId': n.varietyId,
|
||||
'name': n.name,
|
||||
'language': n.language,
|
||||
'region': n.region,
|
||||
},
|
||||
],
|
||||
'externalLinks': [
|
||||
for (final e in snapshot.externalLinks)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: e.id,
|
||||
createdAt: e.createdAt,
|
||||
updatedAt: e.updatedAt,
|
||||
lastAuthor: e.lastAuthor,
|
||||
schemaRowVersion: e.schemaRowVersion,
|
||||
),
|
||||
'parentType': e.parentType.name,
|
||||
'parentId': e.parentId,
|
||||
'url': e.url,
|
||||
'title': e.title,
|
||||
},
|
||||
],
|
||||
'germinationTests': [
|
||||
for (final g in snapshot.germinationTests)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: g.id,
|
||||
createdAt: g.createdAt,
|
||||
updatedAt: g.updatedAt,
|
||||
lastAuthor: g.lastAuthor,
|
||||
schemaRowVersion: g.schemaRowVersion,
|
||||
),
|
||||
'lotId': g.lotId,
|
||||
'testedOn': g.testedOn,
|
||||
'sampleSize': g.sampleSize,
|
||||
'germinatedCount': g.germinatedCount,
|
||||
'notes': g.notes,
|
||||
},
|
||||
],
|
||||
'movements': [
|
||||
for (final m in snapshot.movements)
|
||||
{
|
||||
// Append-only rows: no updatedAt/isDeleted.
|
||||
'id': m.id,
|
||||
'createdAt': m.createdAt,
|
||||
'lastAuthor': m.lastAuthor,
|
||||
'schemaRowVersion': m.schemaRowVersion,
|
||||
'lotId': m.lotId,
|
||||
'type': m.type.name,
|
||||
'occurredOn': m.occurredOn,
|
||||
'counterpartyId': m.counterpartyId,
|
||||
'quantityKind': m.quantityKind,
|
||||
'quantityPrecise': m.quantityPrecise,
|
||||
'quantityLabel': m.quantityLabel,
|
||||
'parentMovementId': m.parentMovementId,
|
||||
'plantareId': m.plantareId,
|
||||
'notes': m.notes,
|
||||
},
|
||||
],
|
||||
'parties': [
|
||||
for (final p in snapshot.parties)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: p.id,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
lastAuthor: p.lastAuthor,
|
||||
schemaRowVersion: p.schemaRowVersion,
|
||||
),
|
||||
'displayName': p.displayName,
|
||||
'publicKey': p.publicKey,
|
||||
'kind': p.kind.name,
|
||||
'note': p.note,
|
||||
},
|
||||
],
|
||||
'attachments': [
|
||||
for (final a in snapshot.attachments)
|
||||
{
|
||||
..._syncMeta(
|
||||
id: a.id,
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
lastAuthor: a.lastAuthor,
|
||||
schemaRowVersion: a.schemaRowVersion,
|
||||
),
|
||||
'parentType': a.parentType.name,
|
||||
'parentId': a.parentId,
|
||||
'kind': a.kind.name,
|
||||
'uri': a.uri,
|
||||
'bytesBase64': a.bytes == null ? null : base64Encode(a.bytes!),
|
||||
'mimeType': a.mimeType,
|
||||
'sortOrder': a.sortOrder,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
InventorySnapshot decode(String source) {
|
||||
final Object? root;
|
||||
try {
|
||||
root = jsonDecode(source);
|
||||
} on FormatException {
|
||||
throw const FormatException('Not a valid JSON file.');
|
||||
}
|
||||
if (root is! Map<String, dynamic>) {
|
||||
throw const FormatException('Not a Tanemaki inventory file.');
|
||||
}
|
||||
final version = root['formatVersion'];
|
||||
if (version is! int) {
|
||||
throw const FormatException('Missing formatVersion.');
|
||||
}
|
||||
if (version > inventoryFormatVersion) {
|
||||
throw FormatException(
|
||||
'This file uses format v$version; this app reads up to '
|
||||
'v$inventoryFormatVersion. Update the app to import it.',
|
||||
);
|
||||
}
|
||||
|
||||
final speciesNamesById = <String, String>{};
|
||||
final varieties = _rows(root, 'varieties', (m) {
|
||||
final speciesId = m['speciesId'] as String?;
|
||||
final scientificName = m['speciesScientificName'] as String?;
|
||||
if (speciesId != null && scientificName != null) {
|
||||
speciesNamesById[speciesId] = scientificName;
|
||||
}
|
||||
return Variety(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
label: _string(m, 'label'),
|
||||
speciesId: speciesId,
|
||||
cultivarName: m['cultivarName'] as String?,
|
||||
category: m['category'] as String?,
|
||||
notes: m['notes'] as String?,
|
||||
);
|
||||
});
|
||||
|
||||
return InventorySnapshot(
|
||||
varieties: varieties,
|
||||
speciesNamesById: speciesNamesById,
|
||||
lots: _rows(root, 'lots', (m) {
|
||||
return Lot(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
varietyId: _string(m, 'varietyId'),
|
||||
// Unknown enum values → safe defaults (data-model §5.2).
|
||||
type: _enumOr(LotType.values, m['type'], LotType.seed),
|
||||
harvestYear: m['harvestYear'] as int?,
|
||||
harvestMonth: m['harvestMonth'] as int?,
|
||||
quantityKind: m['quantityKind'] as String?,
|
||||
quantityPrecise: (m['quantityPrecise'] as num?)?.toDouble(),
|
||||
quantityLabel: m['quantityLabel'] as String?,
|
||||
presentation: _enumOrNull(Presentation.values, m['presentation']),
|
||||
storageLocation: m['storageLocation'] as String?,
|
||||
offerStatus: _enumOr(
|
||||
OfferStatus.values,
|
||||
m['offerStatus'],
|
||||
OfferStatus.private,
|
||||
),
|
||||
seedbankId: m['seedbankId'] as String?,
|
||||
);
|
||||
}),
|
||||
vernacularNames: _rows(root, 'vernacularNames', (m) {
|
||||
return VarietyVernacularName(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
varietyId: _string(m, 'varietyId'),
|
||||
name: _string(m, 'name'),
|
||||
language: m['language'] as String?,
|
||||
region: m['region'] as String?,
|
||||
);
|
||||
}),
|
||||
externalLinks: _rows(root, 'externalLinks', (m) {
|
||||
final parentType = _enumOrNull(ParentType.values, m['parentType']);
|
||||
if (parentType == null) return null; // no safe default → drop row
|
||||
return ExternalLink(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
parentType: parentType,
|
||||
parentId: _string(m, 'parentId'),
|
||||
url: _string(m, 'url'),
|
||||
title: m['title'] as String?,
|
||||
);
|
||||
}),
|
||||
germinationTests: _rows(root, 'germinationTests', (m) {
|
||||
return GerminationTest(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
lotId: _string(m, 'lotId'),
|
||||
testedOn: m['testedOn'] as int?,
|
||||
sampleSize: m['sampleSize'] as int?,
|
||||
germinatedCount: m['germinatedCount'] as int?,
|
||||
notes: m['notes'] as String?,
|
||||
);
|
||||
}),
|
||||
movements: _rows(root, 'movements', (m) {
|
||||
final type = _enumOrNull(MovementType.values, m['type']);
|
||||
if (type == null) return null; // no safe default → drop row
|
||||
return Movement(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
lotId: _string(m, 'lotId'),
|
||||
type: type,
|
||||
occurredOn: m['occurredOn'] as int?,
|
||||
counterpartyId: m['counterpartyId'] as String?,
|
||||
quantityKind: m['quantityKind'] as String?,
|
||||
quantityPrecise: (m['quantityPrecise'] as num?)?.toDouble(),
|
||||
quantityLabel: m['quantityLabel'] as String?,
|
||||
parentMovementId: m['parentMovementId'] as String?,
|
||||
plantareId: m['plantareId'] as String?,
|
||||
notes: m['notes'] as String?,
|
||||
);
|
||||
}),
|
||||
parties: _rows(root, 'parties', (m) {
|
||||
return Party(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
displayName: _string(m, 'displayName'),
|
||||
publicKey: m['publicKey'] as String?,
|
||||
kind: _enumOr(PartyKind.values, m['kind'], PartyKind.person),
|
||||
note: m['note'] as String?,
|
||||
);
|
||||
}),
|
||||
attachments: _rows(root, 'attachments', (m) {
|
||||
final parentType = _enumOrNull(ParentType.values, m['parentType']);
|
||||
final kind = _enumOrNull(AttachmentKind.values, m['kind']);
|
||||
if (parentType == null || kind == null) return null; // drop row
|
||||
final bytesBase64 = m['bytesBase64'] as String?;
|
||||
return Attachment(
|
||||
id: _string(m, 'id'),
|
||||
createdAt: _int(m, 'createdAt'),
|
||||
updatedAt: _string(m, 'updatedAt'),
|
||||
lastAuthor: _string(m, 'lastAuthor'),
|
||||
isDeleted: false,
|
||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||
parentType: parentType,
|
||||
parentId: _string(m, 'parentId'),
|
||||
kind: kind,
|
||||
uri: m['uri'] as String?,
|
||||
bytes: bytesBase64 == null
|
||||
? null
|
||||
: Uint8List.fromList(base64Decode(bytesBase64)),
|
||||
mimeType: m['mimeType'] as String?,
|
||||
sortOrder: _int(m, 'sortOrder', fallback: 0),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _syncMeta({
|
||||
required String id,
|
||||
required int createdAt,
|
||||
required String updatedAt,
|
||||
required String lastAuthor,
|
||||
required int schemaRowVersion,
|
||||
}) => {
|
||||
'id': id,
|
||||
'createdAt': createdAt,
|
||||
'updatedAt': updatedAt,
|
||||
'lastAuthor': lastAuthor,
|
||||
'schemaRowVersion': schemaRowVersion,
|
||||
};
|
||||
|
||||
/// Maps the JSON list under [key] through [decodeRow], skipping entries
|
||||
/// that are not objects, fail to decode, or return null (unknown enums).
|
||||
List<T> _rows<T>(
|
||||
Map<String, dynamic> root,
|
||||
String key,
|
||||
T? Function(Map<String, dynamic>) decodeRow,
|
||||
) {
|
||||
final raw = root[key];
|
||||
if (raw is! List) return const [];
|
||||
final rows = <T>[];
|
||||
for (final entry in raw) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final T? row;
|
||||
try {
|
||||
row = decodeRow(entry);
|
||||
} on FormatException {
|
||||
continue; // a broken row must not abort the whole import
|
||||
}
|
||||
if (row != null) rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
String _string(Map<String, dynamic> m, String key) {
|
||||
final value = m[key];
|
||||
if (value is! String) throw FormatException('Missing "$key"');
|
||||
return value;
|
||||
}
|
||||
|
||||
int _int(Map<String, dynamic> m, String key, {int? fallback}) {
|
||||
final value = m[key];
|
||||
if (value is int) return value;
|
||||
if (fallback != null) return fallback;
|
||||
throw FormatException('Missing "$key"');
|
||||
}
|
||||
|
||||
T _enumOr<T extends Enum>(List<T> values, Object? name, T fallback) =>
|
||||
_enumOrNull(values, name) ?? fallback;
|
||||
|
||||
T? _enumOrNull<T extends Enum>(List<T> values, Object? name) {
|
||||
if (name is! String) return null;
|
||||
return values.asNameMap()[name];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import '../../db/database.dart';
|
||||
|
||||
/// The current version of the interchange JSON format (envelope field
|
||||
/// `formatVersion`). Bump only with additive, documented changes — readers
|
||||
/// must reject files with a *newer* version and tolerate unknown fields in
|
||||
/// the same version (data-model §5.2, §7).
|
||||
const int inventoryFormatVersion = 1;
|
||||
|
||||
/// Identifies files produced by this app (envelope field `appId`).
|
||||
const String inventoryAppId = 'tane';
|
||||
|
||||
/// An in-memory snapshot of the user's inventory, decoupled from any file
|
||||
/// format. Produced by `VarietyRepository.exportInventory()` and consumed by
|
||||
/// `importInventory()`; the JSON/CSV codecs turn it into bytes and back.
|
||||
///
|
||||
/// Rows are the plain Drift data classes (immutable). Tombstones
|
||||
/// (`isDeleted = true`) are never part of a snapshot: this is a user-facing
|
||||
/// export, not the future sync wire format. The bundled species catalog is
|
||||
/// not included either — [speciesNamesById] carries just the scientific
|
||||
/// names needed to re-link varieties on import (species ids are generated
|
||||
/// per install and are not portable).
|
||||
class InventorySnapshot {
|
||||
const InventorySnapshot({
|
||||
this.varieties = const [],
|
||||
this.lots = const [],
|
||||
this.vernacularNames = const [],
|
||||
this.externalLinks = const [],
|
||||
this.germinationTests = const [],
|
||||
this.movements = const [],
|
||||
this.parties = const [],
|
||||
this.attachments = const [],
|
||||
this.speciesNamesById = const {},
|
||||
});
|
||||
|
||||
final List<Variety> varieties;
|
||||
final List<Lot> lots;
|
||||
final List<VarietyVernacularName> vernacularNames;
|
||||
final List<ExternalLink> externalLinks;
|
||||
final List<GerminationTest> germinationTests;
|
||||
final List<Movement> movements;
|
||||
final List<Party> parties;
|
||||
final List<Attachment> attachments;
|
||||
|
||||
/// speciesId → scientificName, for the species referenced by [varieties].
|
||||
final Map<String, String> speciesNamesById;
|
||||
}
|
||||
|
||||
/// What an import did, for user feedback: rows added, rows overwritten by a
|
||||
/// newer incoming version (LWW), and rows skipped (already present and at
|
||||
/// least as recent, or not decodable).
|
||||
class ImportSummary {
|
||||
const ImportSummary({
|
||||
this.inserted = 0,
|
||||
this.updated = 0,
|
||||
this.skipped = 0,
|
||||
});
|
||||
|
||||
final int inserted;
|
||||
final int updated;
|
||||
final int skipped;
|
||||
|
||||
ImportSummary operator +(ImportSummary other) => ImportSummary(
|
||||
inserted: inserted + other.inserted,
|
||||
updated: updated + other.updated,
|
||||
skipped: skipped + other.skipped,
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import 'package:equatable/equatable.dart';
|
|||
|
||||
import '../db/database.dart';
|
||||
import '../db/enums.dart';
|
||||
import 'export_import/import_reconciler.dart';
|
||||
import 'export_import/inventory_snapshot.dart';
|
||||
|
||||
/// A lightweight row for the inventory list (only what the list renders).
|
||||
class VarietyListItem extends Equatable {
|
||||
|
|
@ -826,6 +828,223 @@ class VarietyRepository {
|
|||
return id;
|
||||
}
|
||||
|
||||
/// Snapshots the live inventory (tombstones excluded) for the interchange
|
||||
/// export — data-model §7. Includes photo bytes; the JSON codec embeds them
|
||||
/// as base64 and the CSV codec ignores them.
|
||||
Future<InventorySnapshot> exportInventory() async {
|
||||
final varieties = await (_db.select(
|
||||
_db.varieties,
|
||||
)..where((v) => v.isDeleted.equals(false))).get();
|
||||
final speciesNamesById = await _scientificNamesFor(
|
||||
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
|
||||
);
|
||||
return InventorySnapshot(
|
||||
varieties: varieties,
|
||||
speciesNamesById: speciesNamesById,
|
||||
lots: await (_db.select(
|
||||
_db.lots,
|
||||
)..where((l) => l.isDeleted.equals(false))).get(),
|
||||
vernacularNames: await (_db.select(
|
||||
_db.varietyVernacularNames,
|
||||
)..where((n) => n.isDeleted.equals(false))).get(),
|
||||
externalLinks: await (_db.select(
|
||||
_db.externalLinks,
|
||||
)..where((e) => e.isDeleted.equals(false))).get(),
|
||||
germinationTests: await (_db.select(
|
||||
_db.germinationTests,
|
||||
)..where((g) => g.isDeleted.equals(false))).get(),
|
||||
movements: await _db.select(_db.movements).get(),
|
||||
parties: await (_db.select(
|
||||
_db.parties,
|
||||
)..where((p) => p.isDeleted.equals(false))).get(),
|
||||
attachments: await (_db.select(
|
||||
_db.attachments,
|
||||
)..where((a) => a.isDeleted.equals(false))).get(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
|
||||
/// unknown id → insert preserving the original sync metadata; known mutable
|
||||
/// id → last-writer-wins on the packed HLC `updatedAt`; movements are
|
||||
/// append-only (insert-if-unknown). Runs in one transaction; afterwards the
|
||||
/// local clock absorbs the newest imported stamp so it never runs behind.
|
||||
///
|
||||
/// Species ids are per-install, so incoming `speciesId`s are re-resolved
|
||||
/// against the local catalog by scientific name (unmatched → null).
|
||||
Future<ImportSummary> importInventory(InventorySnapshot snapshot) async {
|
||||
const reconciler = ImportReconciler();
|
||||
final localSpeciesIdByIncoming = await _resolveSpecies(
|
||||
snapshot.speciesNamesById,
|
||||
);
|
||||
final varieties = [
|
||||
for (final v in snapshot.varieties)
|
||||
v.speciesId == null
|
||||
? v
|
||||
: v.copyWith(
|
||||
speciesId: Value(localSpeciesIdByIncoming[v.speciesId]),
|
||||
),
|
||||
];
|
||||
|
||||
var summary = const ImportSummary();
|
||||
await _db.transaction(() async {
|
||||
summary += await _importMutableRows(
|
||||
table: _db.varieties,
|
||||
idColumn: _db.varieties.id,
|
||||
rows: varieties,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.lots,
|
||||
idColumn: _db.lots.id,
|
||||
rows: snapshot.lots,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.varietyVernacularNames,
|
||||
idColumn: _db.varietyVernacularNames.id,
|
||||
rows: snapshot.vernacularNames,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.externalLinks,
|
||||
idColumn: _db.externalLinks.id,
|
||||
rows: snapshot.externalLinks,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.germinationTests,
|
||||
idColumn: _db.germinationTests.id,
|
||||
rows: snapshot.germinationTests,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.parties,
|
||||
idColumn: _db.parties.id,
|
||||
rows: snapshot.parties,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMutableRows(
|
||||
table: _db.attachments,
|
||||
idColumn: _db.attachments.id,
|
||||
rows: snapshot.attachments,
|
||||
idOf: (r) => r.id,
|
||||
updatedAtOf: (r) => r.updatedAt,
|
||||
reconciler: reconciler,
|
||||
);
|
||||
summary += await _importMovements(snapshot.movements, reconciler);
|
||||
});
|
||||
|
||||
final maxIncoming = reconciler.maxHlc([
|
||||
for (final v in snapshot.varieties) v.updatedAt,
|
||||
for (final l in snapshot.lots) l.updatedAt,
|
||||
for (final n in snapshot.vernacularNames) n.updatedAt,
|
||||
for (final e in snapshot.externalLinks) e.updatedAt,
|
||||
for (final g in snapshot.germinationTests) g.updatedAt,
|
||||
for (final p in snapshot.parties) p.updatedAt,
|
||||
for (final a in snapshot.attachments) a.updatedAt,
|
||||
]);
|
||||
if (maxIncoming != null) {
|
||||
_clock = _clock.receiveEvent(maxIncoming, _now());
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// Maps incoming species ids to local catalog ids by scientific name.
|
||||
Future<Map<String, String?>> _resolveSpecies(
|
||||
Map<String, String> speciesNamesById,
|
||||
) async {
|
||||
final resolved = <String, String?>{};
|
||||
for (final entry in speciesNamesById.entries) {
|
||||
final local =
|
||||
await (_db.select(_db.species)
|
||||
..where(
|
||||
(s) =>
|
||||
s.scientificName.equals(entry.value) &
|
||||
s.isDeleted.equals(false),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
resolved[entry.key] = local?.id;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// LWW import of one mutable table. Existing rows are looked up including
|
||||
/// tombstones, so a locally-deleted row only resurrects when the incoming
|
||||
/// version is strictly newer — plain last-writer-wins.
|
||||
Future<ImportSummary> _importMutableRows<T extends Table, R>({
|
||||
required TableInfo<T, R> table,
|
||||
required GeneratedColumn<String> idColumn,
|
||||
required List<R> rows,
|
||||
required String Function(R) idOf,
|
||||
required String Function(R) updatedAtOf,
|
||||
required ImportReconciler reconciler,
|
||||
}) async {
|
||||
if (rows.isEmpty) return const ImportSummary();
|
||||
final byId = {for (final r in rows) idOf(r): r};
|
||||
final existing = await (_db.select(
|
||||
table,
|
||||
)..where((_) => idColumn.isIn(byId.keys))).get();
|
||||
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
|
||||
|
||||
var inserted = 0, updated = 0, skipped = 0;
|
||||
for (final row in byId.values) {
|
||||
final action = reconciler.reconcileMutable(
|
||||
existingUpdatedAt: existingUpdatedAt[idOf(row)],
|
||||
incomingUpdatedAt: updatedAtOf(row),
|
||||
);
|
||||
switch (action) {
|
||||
case ReconcileAction.insert:
|
||||
await _db.into(table).insert(row as Insertable<R>);
|
||||
inserted++;
|
||||
case ReconcileAction.update:
|
||||
await _db.update(table).replace(row as Insertable<R>);
|
||||
updated++;
|
||||
case ReconcileAction.skip:
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
|
||||
}
|
||||
|
||||
/// Movements are append-only: insert unknown ids, never touch known ones.
|
||||
Future<ImportSummary> _importMovements(
|
||||
List<Movement> movements,
|
||||
ImportReconciler reconciler,
|
||||
) async {
|
||||
if (movements.isEmpty) return const ImportSummary();
|
||||
final ids = movements.map((m) => m.id).toList();
|
||||
final existing = await (_db.select(
|
||||
_db.movements,
|
||||
)..where((m) => m.id.isIn(ids))).get();
|
||||
final existingIds = existing.map((m) => m.id).toSet();
|
||||
|
||||
var inserted = 0, skipped = 0;
|
||||
for (final movement in movements) {
|
||||
final action = reconciler.reconcileAppendOnly(
|
||||
exists: existingIds.contains(movement.id),
|
||||
);
|
||||
if (action == ReconcileAction.insert) {
|
||||
await _db.into(_db.movements).insert(movement);
|
||||
inserted++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return ImportSummary(inserted: inserted, skipped: skipped);
|
||||
}
|
||||
|
||||
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
|
||||
final hasQuantity =
|
||||
l.quantityKind != null ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue