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,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue