diff --git a/apps/app_seeds/lib/data/export_import/import_reconciler.dart b/apps/app_seeds/lib/data/export_import/import_reconciler.dart new file mode 100644 index 0000000..fba30f6 --- /dev/null +++ b/apps/app_seeds/lib/data/export_import/import_reconciler.dart @@ -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 packedHlcs) { + Hlc? max; + for (final packed in packedHlcs) { + final hlc = Hlc.parse(packed); + if (max == null || hlc.compareTo(max) > 0) max = hlc; + } + return max; + } +} diff --git a/apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart b/apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart new file mode 100644 index 0000000..d64c988 --- /dev/null +++ b/apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart @@ -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 _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 = >{}; + for (final lot in snapshot.lots) { + lotsByVariety.putIfAbsent(lot.varietyId, () => []).add(lot); + } + final namesByVariety = >{}; + for (final n in snapshot.vernacularNames) { + namesByVariety + .putIfAbsent(n.varietyId, () => []) + .add(n.language == null ? n.name : '${n.name} (${n.language})'); + } + final linksByVariety = >{}; + 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 = [_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 []; + 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 _latestGerminationRates(List tests) { + final latest = {}; + 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('"', '""')}"'; + } +} diff --git a/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart new file mode 100644 index 0000000..4272f6f --- /dev/null +++ b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart @@ -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) { + 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 = {}; + 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 _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 _rows( + Map root, + String key, + T? Function(Map) decodeRow, + ) { + final raw = root[key]; + if (raw is! List) return const []; + final rows = []; + for (final entry in raw) { + if (entry is! Map) 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 m, String key) { + final value = m[key]; + if (value is! String) throw FormatException('Missing "$key"'); + return value; + } + + int _int(Map 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(List values, Object? name, T fallback) => + _enumOrNull(values, name) ?? fallback; + + T? _enumOrNull(List values, Object? name) { + if (name is! String) return null; + return values.asNameMap()[name]; + } +} diff --git a/apps/app_seeds/lib/data/export_import/inventory_snapshot.dart b/apps/app_seeds/lib/data/export_import/inventory_snapshot.dart new file mode 100644 index 0000000..27cee28 --- /dev/null +++ b/apps/app_seeds/lib/data/export_import/inventory_snapshot.dart @@ -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 varieties; + final List lots; + final List vernacularNames; + final List externalLinks; + final List germinationTests; + final List movements; + final List parties; + final List attachments; + + /// speciesId → scientificName, for the species referenced by [varieties]. + final Map 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, + ); +} diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index e693dff..92ca7e1 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 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().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 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> _resolveSpecies( + Map speciesNamesById, + ) async { + final resolved = {}; + 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 _importMutableRows({ + required TableInfo table, + required GeneratedColumn idColumn, + required List 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); + inserted++; + case ReconcileAction.update: + await _db.update(table).replace(row as Insertable); + 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 _importMovements( + List 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 germinationTests) { final hasQuantity = l.quantityKind != null || diff --git a/apps/app_seeds/lib/db/database.dart b/apps/app_seeds/lib/db/database.dart index 6c475fb..bc61f17 100644 --- a/apps/app_seeds/lib/db/database.dart +++ b/apps/app_seeds/lib/db/database.dart @@ -26,8 +26,12 @@ part 'database.g.dart'; class AppDatabase extends _$AppDatabase { AppDatabase(super.e); + /// Current schema version; also stamped into interchange exports so an + /// importer knows which app generation wrote the file (data-model §7). + static const int currentSchemaVersion = 5; + @override - int get schemaVersion => 5; + int get schemaVersion => currentSchemaVersion; @override MigrationStrategy get migration => MigrationStrategy( diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index fa07af6..dc35ab6 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -12,6 +12,9 @@ import '../db/database.dart'; import '../db/encrypted_executor.dart'; import '../security/secret_store.dart'; import '../security/secure_key_store.dart'; +import '../services/export_import_service.dart'; +import '../services/file_picker_file_service.dart'; +import '../services/file_service.dart'; /// The app's service locator. Kept to the composition root — widgets get their /// repositories from here (or via BlocProvider), never by reaching into it deep @@ -37,12 +40,22 @@ Future configureDependencies() async { final speciesRepository = SpeciesRepository(database, idGen: IdGen()); await speciesRepository.seedBundled(await loadBundledSpecies()); + final varietyRepository = VarietyRepository( + database, + idGen: IdGen(), + nodeId: nodeId, + ); + + const fileService = FilePickerFileService(); + getIt ..registerSingleton(keyStore) ..registerSingleton(database) ..registerSingleton(speciesRepository) - ..registerSingleton( - VarietyRepository(database, idGen: IdGen(), nodeId: nodeId), + ..registerSingleton(varietyRepository) + ..registerSingleton(fileService) + ..registerSingleton( + ExportImportService(repository: varietyRepository, files: fileService), ); } diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 99579b9..c4658e5 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -43,6 +43,23 @@ "aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.", "aboutOpen": "About Tanemaki" }, + "backup": { + "title": "Backup", + "exportCsv": "Export as CSV", + "exportCsvSubtitle": "For spreadsheets — no photos", + "exportJson": "Export as JSON", + "exportJsonSubtitle": "Complete copy, can be imported back", + "importJson": "Import from JSON", + "importJsonSubtitle": "Merge a saved copy into this inventory", + "importConfirmTitle": "Import a saved copy?", + "importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.", + "importAction": "Import", + "exportSaved": "Copy saved", + "cancelled": "Cancelled", + "importDone": "Imported: {added} new, {updated} updated", + "importFailed": "This file could not be read as a Tanemaki copy", + "failed": "Something went wrong" + }, "about": { "title": "About", "kanji": "種まき", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 9b47139..52c5e4c 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -43,6 +43,23 @@ "aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.", "aboutOpen": "Acerca de Tanemaki" }, + "backup": { + "title": "Copia de seguridad", + "exportCsv": "Exportar a CSV", + "exportCsvSubtitle": "Para hojas de cálculo — sin fotos", + "exportJson": "Exportar a JSON", + "exportJsonSubtitle": "Copia completa, se puede volver a importar", + "importJson": "Importar desde JSON", + "importJsonSubtitle": "Fusiona una copia guardada con este inventario", + "importConfirmTitle": "¿Importar una copia guardada?", + "importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.", + "importAction": "Importar", + "exportSaved": "Copia guardada", + "cancelled": "Cancelado", + "importDone": "Importado: {added} nuevas, {updated} actualizadas", + "importFailed": "Este fichero no se pudo leer como una copia de Tanemaki", + "failed": "Algo ha salido mal" + }, "about": { "title": "Acerca de", "kanji": "種まき", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 0f11cdb..944369f 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 336 (168 per locale) +/// Strings: 366 (183 per locale) /// -/// Built on 2026-07-09 at 09:55 UTC +/// Built on 2026-07-09 at 10:41 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 818a1d1..9533dee 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -46,6 +46,7 @@ class Translations with BaseTranslations { late final Translations$photo$en photo = Translations$photo$en.internal(_root); late final Translations$menu$en menu = Translations$menu$en.internal(_root); late final Translations$settings$en settings = Translations$settings$en.internal(_root); + late final Translations$backup$en backup = Translations$backup$en.internal(_root); late final Translations$about$en about = Translations$about$en.internal(_root); late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root); late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root); @@ -209,6 +210,60 @@ class Translations$settings$en { String get aboutOpen => 'About Tanemaki'; } +// Path: backup +class Translations$backup$en { + Translations$backup$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Backup' + String get title => 'Backup'; + + /// en: 'Export as CSV' + String get exportCsv => 'Export as CSV'; + + /// en: 'For spreadsheets — no photos' + String get exportCsvSubtitle => 'For spreadsheets — no photos'; + + /// en: 'Export as JSON' + String get exportJson => 'Export as JSON'; + + /// en: 'Complete copy, can be imported back' + String get exportJsonSubtitle => 'Complete copy, can be imported back'; + + /// en: 'Import from JSON' + String get importJson => 'Import from JSON'; + + /// en: 'Merge a saved copy into this inventory' + String get importJsonSubtitle => 'Merge a saved copy into this inventory'; + + /// en: 'Import a saved copy?' + String get importConfirmTitle => 'Import a saved copy?'; + + /// en: 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.' + String get importConfirmBody => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.'; + + /// en: 'Import' + String get importAction => 'Import'; + + /// en: 'Copy saved' + String get exportSaved => 'Copy saved'; + + /// en: 'Cancelled' + String get cancelled => 'Cancelled'; + + /// en: 'Imported: {added} new, {updated} updated' + String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated'; + + /// en: 'This file could not be read as a Tanemaki copy' + String get importFailed => 'This file could not be read as a Tanemaki copy'; + + /// en: 'Something went wrong' + String get failed => 'Something went wrong'; +} + // Path: about class Translations$about$en { Translations$about$en.internal(this._root); @@ -976,6 +1031,21 @@ extension on Translations { 'settings.about' => 'About', 'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.', 'settings.aboutOpen' => 'About Tanemaki', + 'backup.title' => 'Backup', + 'backup.exportCsv' => 'Export as CSV', + 'backup.exportCsvSubtitle' => 'For spreadsheets — no photos', + 'backup.exportJson' => 'Export as JSON', + 'backup.exportJsonSubtitle' => 'Complete copy, can be imported back', + 'backup.importJson' => 'Import from JSON', + 'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory', + 'backup.importConfirmTitle' => 'Import a saved copy?', + 'backup.importConfirmBody' => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.', + 'backup.importAction' => 'Import', + 'backup.exportSaved' => 'Copy saved', + 'backup.cancelled' => 'Cancelled', + 'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated', + 'backup.importFailed' => 'This file could not be read as a Tanemaki copy', + 'backup.failed' => 'Something went wrong', 'about.title' => 'About', 'about.kanji' => '種まき', 'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 9979397..6a066aa 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -45,6 +45,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Acerca de Tanemaki'; } +// Path: backup +class _Translations$backup$es extends Translations$backup$en { + _Translations$backup$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Copia de seguridad'; + @override String get exportCsv => 'Exportar a CSV'; + @override String get exportCsvSubtitle => 'Para hojas de cálculo — sin fotos'; + @override String get exportJson => 'Exportar a JSON'; + @override String get exportJsonSubtitle => 'Copia completa, se puede volver a importar'; + @override String get importJson => 'Importar desde JSON'; + @override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario'; + @override String get importConfirmTitle => '¿Importar una copia guardada?'; + @override String get importConfirmBody => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.'; + @override String get importAction => 'Importar'; + @override String get exportSaved => 'Copia guardada'; + @override String get cancelled => 'Cancelado'; + @override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas'; + @override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki'; + @override String get failed => 'Algo ha salido mal'; +} + // Path: about class _Translations$about$es extends Translations$about$en { _Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -661,6 +686,21 @@ extension on TranslationsEs { 'settings.about' => 'Acerca de', 'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.', 'settings.aboutOpen' => 'Acerca de Tanemaki', + 'backup.title' => 'Copia de seguridad', + 'backup.exportCsv' => 'Exportar a CSV', + 'backup.exportCsvSubtitle' => 'Para hojas de cálculo — sin fotos', + 'backup.exportJson' => 'Exportar a JSON', + 'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar', + 'backup.importJson' => 'Importar desde JSON', + 'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario', + 'backup.importConfirmTitle' => '¿Importar una copia guardada?', + 'backup.importConfirmBody' => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.', + 'backup.importAction' => 'Importar', + 'backup.exportSaved' => 'Copia guardada', + 'backup.cancelled' => 'Cancelado', + 'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas', + 'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki', + 'backup.failed' => 'Algo ha salido mal', 'about.title' => 'Acerca de', 'about.kanji' => '種まき', 'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.', diff --git a/apps/app_seeds/lib/services/export_import_service.dart b/apps/app_seeds/lib/services/export_import_service.dart new file mode 100644 index 0000000..bae2a83 --- /dev/null +++ b/apps/app_seeds/lib/services/export_import_service.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +import '../data/export_import/inventory_csv_codec.dart'; +import '../data/export_import/inventory_json_codec.dart'; +import '../data/export_import/inventory_snapshot.dart'; +import '../data/variety_repository.dart'; +import 'file_service.dart'; + +/// End-to-end export/import flows: repository snapshot ↔ codec ↔ the file the +/// user picked. UI-free — screens call these and render the outcome. +class ExportImportService { + ExportImportService({ + required VarietyRepository repository, + required FileService files, + DateTime Function()? now, + }) : _repository = repository, + _files = files, + _now = now ?? DateTime.now; + + final VarietyRepository _repository; + final FileService _files; + final DateTime Function() _now; + + static const _jsonCodec = InventoryJsonCodec(); + static const _csvCodec = InventoryCsvCodec(); + + /// Exports the inventory as interchange JSON (data-model §7). Returns true + /// when saved, false when the user cancelled the save dialog. + Future exportJson() async { + final snapshot = await _repository.exportInventory(); + final path = await _files.saveFile( + suggestedName: _fileName('json'), + bytes: utf8.encode(_jsonCodec.encode(snapshot)), + ); + return path != null; + } + + /// Exports the inventory as flat CSV (export-only). Returns true when + /// saved, false when the user cancelled the save dialog. + Future exportCsv() async { + final snapshot = await _repository.exportInventory(); + final path = await _files.saveFile( + suggestedName: _fileName('csv'), + bytes: utf8.encode(_csvCodec.encode(snapshot)), + ); + return path != null; + } + + /// Imports an interchange JSON file, merging by id (LWW). Returns the + /// summary, or null when the user cancelled the file dialog. Throws + /// [FormatException] when the file is not a readable inventory export. + Future importJson() async { + final bytes = await _files.pickFileBytes(allowedExtensions: ['json']); + if (bytes == null) return null; + final snapshot = _jsonCodec.decode(utf8.decode(bytes)); + return _repository.importInventory(snapshot); + } + + String _fileName(String extension) { + final date = _now().toIso8601String().substring(0, 10); + return 'tanemaki-inventory-$date.$extension'; + } +} diff --git a/apps/app_seeds/lib/services/file_picker_file_service.dart b/apps/app_seeds/lib/services/file_picker_file_service.dart new file mode 100644 index 0000000..0934d00 --- /dev/null +++ b/apps/app_seeds/lib/services/file_picker_file_service.dart @@ -0,0 +1,47 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; + +import 'file_service.dart'; + +/// [FileService] backed by the `file_picker` plugin (one plugin covers the +/// save and open dialogs on Android, iOS and desktop). +class FilePickerFileService implements FileService { + const FilePickerFileService(); + + @override + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async { + final path = await FilePicker.platform.saveFile( + fileName: suggestedName, + bytes: bytes, + ); + if (path == null) return null; + // On mobile the plugin writes [bytes] itself; on desktop it only returns + // the chosen path, so write them here. + if (!Platform.isAndroid && !Platform.isIOS) { + await File(path).writeAsBytes(bytes, flush: true); + } + return path; + } + + @override + Future pickFileBytes({List? allowedExtensions}) async { + final result = await FilePicker.platform.pickFiles( + type: allowedExtensions == null ? FileType.any : FileType.custom, + allowedExtensions: allowedExtensions, + withData: true, + ); + final file = result?.files.singleOrNull; + if (file == null) return null; + // withData should populate bytes; fall back to the path just in case. + final bytes = file.bytes; + if (bytes != null) return bytes; + final path = file.path; + if (path == null) return null; + return File(path).readAsBytes(); + } +} diff --git a/apps/app_seeds/lib/services/file_service.dart b/apps/app_seeds/lib/services/file_service.dart new file mode 100644 index 0000000..3d29dcc --- /dev/null +++ b/apps/app_seeds/lib/services/file_service.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; + +/// Cross-platform "save a file / open a file" boundary, so everything above +/// it (services, tests) stays free of platform channels. Implementations must +/// never leave plaintext temp copies behind — bytes go straight between +/// memory and the destination the user picked (security-privacy.md). +abstract class FileService { + /// Asks the user where to save [bytes] (suggesting [suggestedName]) and + /// writes them there. Returns the chosen path, or null if they cancelled. + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }); + + /// Asks the user to pick a file (optionally restricted to + /// [allowedExtensions], without dots) and returns its bytes, or null if + /// they cancelled. + Future pickFileBytes({List? allowedExtensions}); +} diff --git a/apps/app_seeds/lib/ui/backup_section.dart b/apps/app_seeds/lib/ui/backup_section.dart new file mode 100644 index 0000000..156b636 --- /dev/null +++ b/apps/app_seeds/lib/ui/backup_section.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../di/injector.dart'; +import '../i18n/strings.g.dart'; +import '../services/export_import_service.dart'; + +/// The three backup actions shown in Settings: export CSV, export JSON and +/// import JSON. Import asks for confirmation first (it merges into the live +/// inventory), then reports what happened in a SnackBar. +class BackupSection extends StatelessWidget { + const BackupSection({this.service, super.key}); + + /// Injectable for widget tests; the app resolves it lazily (on tap) from + /// the service locator so merely mounting Settings needs no DI setup. + final ExportImportService? service; + + ExportImportService get _service => service ?? getIt(); + + @override + Widget build(BuildContext context) { + final t = context.t; + return Column( + children: [ + ListTile( + leading: const Icon(Icons.table_view_outlined), + title: Text(t.backup.exportCsv), + subtitle: Text(t.backup.exportCsvSubtitle), + onTap: () => _runExport(context, () => _service.exportCsv()), + ), + ListTile( + leading: const Icon(Icons.file_download_outlined), + title: Text(t.backup.exportJson), + subtitle: Text(t.backup.exportJsonSubtitle), + onTap: () => _runExport(context, () => _service.exportJson()), + ), + ListTile( + leading: const Icon(Icons.file_upload_outlined), + title: Text(t.backup.importJson), + subtitle: Text(t.backup.importJsonSubtitle), + onTap: () => _runImport(context), + ), + ], + ); + } + + Future _runExport( + BuildContext context, + Future Function() export, + ) async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + try { + final saved = await export(); + _show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled); + } on Object { + _show(messenger, t.backup.failed); + } + } + + Future _runImport(BuildContext context) async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(t.backup.importConfirmTitle), + content: Text(t.backup.importConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(t.backup.importAction), + ), + ], + ), + ); + if (confirmed != true) return; + try { + final summary = await _service.importJson(); + _show( + messenger, + summary == null + ? t.backup.cancelled + : t.backup.importDone( + added: summary.inserted, + updated: summary.updated, + ), + ); + } on FormatException { + _show(messenger, t.backup.importFailed); + } on Object { + _show(messenger, t.backup.failed); + } + } + + void _show(ScaffoldMessengerState messenger, String message) { + messenger.showSnackBar(SnackBar(content: Text(message))); + } +} diff --git a/apps/app_seeds/lib/ui/settings_screen.dart b/apps/app_seeds/lib/ui/settings_screen.dart index 775f1d5..d044eb6 100644 --- a/apps/app_seeds/lib/ui/settings_screen.dart +++ b/apps/app_seeds/lib/ui/settings_screen.dart @@ -2,11 +2,17 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; +import '../services/export_import_service.dart'; +import 'backup_section.dart'; import 'theme.dart'; -/// Basic settings: app language and an "about" section. +/// Basic settings: app language, backup (export/import) and an "about" +/// section. [exportImport] is injectable so widget tests can pass a fake; +/// the app resolves it lazily from the service locator. class SettingsScreen extends StatelessWidget { - const SettingsScreen({super.key}); + const SettingsScreen({this.exportImport, super.key}); + + final ExportImportService? exportImport; @override Widget build(BuildContext context) { @@ -33,6 +39,9 @@ class SettingsScreen extends StatelessWidget { onTap: () => LocaleSettings.useDeviceLocale(), ), const Divider(), + _SectionHeader(t.backup.title), + BackupSection(service: exportImport), + const Divider(), _SectionHeader(t.settings.about), ListTile( leading: Image.asset('assets/logo.png', width: 32), diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index e0ae702..e0ba7c8 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -44,6 +44,8 @@ dependencies: # Routing + media + paths. go_router: ^14.6.2 image_picker: ^1.1.2 + # Save/open dialogs for the inventory export/import (MIT license). + file_picker: ^10.1.2 path: ^1.9.0 path_provider: ^2.1.5 diff --git a/apps/app_seeds/test/data/export_import/import_reconciler_test.dart b/apps/app_seeds/test/data/export_import/import_reconciler_test.dart new file mode 100644 index 0000000..d89200f --- /dev/null +++ b/apps/app_seeds/test/data/export_import/import_reconciler_test.dart @@ -0,0 +1,98 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/import_reconciler.dart'; + +void main() { + const reconciler = ImportReconciler(); + + String stamp(int millis, {int counter = 0, String node = 'node-a'}) => + Hlc(millis: millis, counter: counter, nodeId: node).pack(); + + group('reconcileMutable (LWW by packed HLC)', () { + test('unknown id inserts', () { + expect( + reconciler.reconcileMutable( + existingUpdatedAt: null, + incomingUpdatedAt: stamp(1000), + ), + ReconcileAction.insert, + ); + }); + + test('newer incoming stamp updates', () { + expect( + reconciler.reconcileMutable( + existingUpdatedAt: stamp(1000), + incomingUpdatedAt: stamp(2000), + ), + ReconcileAction.update, + ); + }); + + test('older incoming stamp skips', () { + expect( + reconciler.reconcileMutable( + existingUpdatedAt: stamp(2000), + incomingUpdatedAt: stamp(1000), + ), + ReconcileAction.skip, + ); + }); + + test('identical stamp skips (re-import is a no-op)', () { + expect( + reconciler.reconcileMutable( + existingUpdatedAt: stamp(1000), + incomingUpdatedAt: stamp(1000), + ), + ReconcileAction.skip, + ); + }); + + test('same millis resolves by counter then node id', () { + expect( + reconciler.reconcileMutable( + existingUpdatedAt: stamp(1000, counter: 1), + incomingUpdatedAt: stamp(1000, counter: 2), + ), + ReconcileAction.update, + ); + expect( + reconciler.reconcileMutable( + existingUpdatedAt: stamp(1000, node: 'node-a'), + incomingUpdatedAt: stamp(1000, node: 'node-b'), + ), + ReconcileAction.update, + ); + }); + }); + + group('reconcileAppendOnly', () { + test('unknown id inserts, known id skips (never updates)', () { + expect( + reconciler.reconcileAppendOnly(exists: false), + ReconcileAction.insert, + ); + expect( + reconciler.reconcileAppendOnly(exists: true), + ReconcileAction.skip, + ); + }); + }); + + group('maxHlc', () { + test('returns the newest stamp', () { + final max = reconciler.maxHlc([ + stamp(1000), + stamp(3000, counter: 2), + stamp(3000, counter: 1), + stamp(2000), + ]); + expect(max, Hlc(millis: 3000, counter: 2, nodeId: 'node-a')); + }); + + test('returns null when empty', () { + expect(reconciler.maxHlc(const []), isNull); + }); + }); +} diff --git a/apps/app_seeds/test/data/export_import/inventory_csv_codec_test.dart b/apps/app_seeds/test/data/export_import/inventory_csv_codec_test.dart new file mode 100644 index 0000000..49629c0 --- /dev/null +++ b/apps/app_seeds/test/data/export_import/inventory_csv_codec_test.dart @@ -0,0 +1,197 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/inventory_csv_codec.dart'; +import 'package:tane/data/export_import/inventory_snapshot.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +void main() { + const codec = InventoryCsvCodec(); + + Variety variety({ + String id = 'var-1', + String label = 'Grandma tomato', + String? notes, + }) => Variety( + id: id, + createdAt: 0, + updatedAt: '000000000001000:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + label: label, + speciesId: 'sp-1', + cultivarName: null, + category: 'Solanaceae', + notes: notes, + ); + + Lot lot({String id = 'lot-1', String varietyId = 'var-1'}) => Lot( + id: id, + createdAt: 0, + updatedAt: '000000000002000:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + varietyId: varietyId, + type: LotType.seed, + harvestYear: 2024, + harvestMonth: null, + quantityKind: 'cob', + quantityPrecise: 2, + quantityLabel: null, + presentation: null, + storageLocation: null, + offerStatus: OfferStatus.private, + seedbankId: null, + ); + + List rowsOf(String csv) => + csv.trimRight().split('\r\n'); + + test('emits a header and one row per lot', () { + final csv = codec.encode( + InventorySnapshot( + varieties: [variety()], + lots: [lot(), lot(id: 'lot-2')], + speciesNamesById: const {'sp-1': 'Solanum lycopersicum'}, + ), + ); + final rows = rowsOf(csv); + expect(rows, hasLength(3)); + expect(rows.first, startsWith('variety_id,variety_label,category')); + expect(rows[1], contains('lot-1')); + expect(rows[1], contains('Solanum lycopersicum')); + expect(rows[1], contains('cob')); + expect(rows[2], contains('lot-2')); + }); + + test('a variety without lots still gets one row', () { + final csv = codec.encode(InventorySnapshot(varieties: [variety()])); + final rows = rowsOf(csv); + expect(rows, hasLength(2)); + expect(rows[1], startsWith('var-1,Grandma tomato,Solanaceae')); + // Same cell count as the header even with empty lot columns. + expect(rows[1].split(',').length, rows.first.split(',').length); + }); + + test('escapes commas, quotes and newlines per RFC 4180', () { + final csv = codec.encode( + InventorySnapshot( + varieties: [ + variety(label: 'Bean, "the best"', notes: 'line one\nline two'), + ], + ), + ); + expect(csv, contains('"Bean, ""the best"""')); + expect(csv, contains('"line one\nline two"')); + }); + + test('joins vernacular names and links; never includes photo bytes', () { + final snapshot = InventorySnapshot( + varieties: [variety()], + vernacularNames: const [ + VarietyVernacularName( + id: 'n1', + createdAt: 0, + updatedAt: '000000000001000:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + varietyId: 'var-1', + name: 'tomate de la abuela', + language: 'es', + region: null, + ), + VarietyVernacularName( + id: 'n2', + createdAt: 0, + updatedAt: '000000000001000:00001:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + varietyId: 'var-1', + name: 'granny tomato', + language: null, + region: null, + ), + ], + externalLinks: const [ + ExternalLink( + id: 'l1', + createdAt: 0, + updatedAt: '000000000001000:00002:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + parentType: ParentType.variety, + parentId: 'var-1', + url: 'https://example.org', + title: 'Wiki', + ), + ], + attachments: [ + Attachment( + id: 'a1', + createdAt: 0, + updatedAt: '000000000001000:00003:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + parentType: ParentType.variety, + parentId: 'var-1', + kind: AttachmentKind.photo, + uri: null, + bytes: Uint8List.fromList([9, 9, 9]), + mimeType: 'image/jpeg', + sortOrder: 0, + ), + ], + ); + final csv = codec.encode(snapshot); + expect(csv, contains('tomate de la abuela (es); granny tomato')); + expect(csv, contains('Wiki|https://example.org')); + expect(csv, isNot(contains('9,9,9'))); + expect(csv.toLowerCase(), isNot(contains('base64'))); + }); + + test('reports the latest germination rate per lot', () { + final csv = codec.encode( + InventorySnapshot( + varieties: [variety()], + lots: [lot()], + germinationTests: const [ + GerminationTest( + id: 'g-old', + createdAt: 0, + updatedAt: '000000000001000:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + lotId: 'lot-1', + testedOn: 100, + sampleSize: 10, + germinatedCount: 2, + notes: null, + ), + GerminationTest( + id: 'g-new', + createdAt: 0, + updatedAt: '000000000001000:00001:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + lotId: 'lot-1', + testedOn: 200, + sampleSize: 10, + germinatedCount: 8, + notes: null, + ), + ], + ), + ); + expect(csv, contains('0.80')); + expect(csv, isNot(contains('0.20'))); + }); +} diff --git a/apps/app_seeds/test/data/export_import/inventory_json_codec_test.dart b/apps/app_seeds/test/data/export_import/inventory_json_codec_test.dart new file mode 100644 index 0000000..da06524 --- /dev/null +++ b/apps/app_seeds/test/data/export_import/inventory_json_codec_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/inventory_json_codec.dart'; +import 'package:tane/data/export_import/inventory_snapshot.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +void main() { + const codec = InventoryJsonCodec(); + + const variety = Variety( + id: 'var-1', + createdAt: 1000, + updatedAt: '000000000001000:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + label: 'Grandma tomato', + speciesId: 'sp-1', + cultivarName: 'Marmande', + category: 'Solanaceae', + notes: 'Keeps well', + ); + const lot = Lot( + id: 'lot-1', + createdAt: 1001, + updatedAt: '000000000001001:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + varietyId: 'var-1', + type: LotType.seed, + harvestYear: 2024, + harvestMonth: 6, + quantityKind: 'cob', + quantityPrecise: 2, + quantityLabel: null, + presentation: null, + storageLocation: 'fridge', + offerStatus: OfferStatus.private, + seedbankId: null, + ); + const name = VarietyVernacularName( + id: 'name-1', + createdAt: 1002, + updatedAt: '000000000001002:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + varietyId: 'var-1', + name: 'tomate de la abuela', + language: 'es', + region: 'Madrid', + ); + const link = ExternalLink( + id: 'link-1', + createdAt: 1003, + updatedAt: '000000000001003:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + parentType: ParentType.variety, + parentId: 'var-1', + url: 'https://example.org/tomato', + title: 'Wiki', + ); + const germination = GerminationTest( + id: 'germ-1', + createdAt: 1004, + updatedAt: '000000000001004:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + lotId: 'lot-1', + testedOn: 1700000000000, + sampleSize: 10, + germinatedCount: 8, + notes: null, + ); + const movement = Movement( + id: 'mov-1', + createdAt: 1005, + lastAuthor: 'node-a', + schemaRowVersion: 1, + lotId: 'lot-1', + type: MovementType.received, + occurredOn: 1690000000000, + counterpartyId: 'party-1', + quantityKind: 'handful', + quantityPrecise: null, + quantityLabel: 'a handful', + parentMovementId: null, + plantareId: null, + notes: 'from the fair', + ); + const party = Party( + id: 'party-1', + createdAt: 1006, + updatedAt: '000000000001006:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + displayName: 'Village bank', + publicKey: null, + kind: PartyKind.collective, + note: null, + ); + final photo = Attachment( + id: 'att-1', + createdAt: 1007, + updatedAt: '000000000001007:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + parentType: ParentType.variety, + parentId: 'var-1', + kind: AttachmentKind.photo, + uri: null, + bytes: Uint8List.fromList([1, 2, 3, 255]), + mimeType: 'image/jpeg', + sortOrder: -1, + ); + + final snapshot = InventorySnapshot( + varieties: const [variety], + lots: const [lot], + vernacularNames: const [name], + externalLinks: const [link], + germinationTests: const [germination], + movements: const [movement], + parties: const [party], + attachments: [photo], + speciesNamesById: const {'sp-1': 'Solanum lycopersicum'}, + ); + + test('encode → decode round-trips every table and field', () { + final decoded = codec.decode(codec.encode(snapshot)); + + expect(decoded.varieties, [variety]); + expect(decoded.lots, [lot]); + expect(decoded.vernacularNames, [name]); + expect(decoded.externalLinks, [link]); + expect(decoded.germinationTests, [germination]); + expect(decoded.movements, [movement]); + expect(decoded.parties, [party]); + expect(decoded.attachments, [photo]); + expect(decoded.speciesNamesById, {'sp-1': 'Solanum lycopersicum'}); + }); + + test('photo bytes travel as base64', () { + final root = jsonDecode(codec.encode(snapshot)) as Map; + final attachment = + (root['attachments'] as List).single as Map; + expect(attachment['bytesBase64'], base64Encode([1, 2, 3, 255])); + expect(attachment.containsKey('bytes'), isFalse); + }); + + test('envelope carries format/schema versions and app id', () { + final root = jsonDecode(codec.encode(snapshot)) as Map; + expect(root['formatVersion'], inventoryFormatVersion); + expect(root['schemaVersion'], AppDatabase.currentSchemaVersion); + expect(root['appId'], inventoryAppId); + }); + + test('unknown fields are ignored (forward compatibility, §5.2)', () { + final root = jsonDecode(codec.encode(snapshot)) as Map; + root['someFutureSection'] = {'x': 1}; + ((root['varieties'] as List).single as Map)['futureField'] = + 'ignored'; + final decoded = codec.decode(jsonEncode(root)); + expect(decoded.varieties, [variety]); + }); + + test('unknown enum values fall back or drop the row (§5.2)', () { + final root = jsonDecode(codec.encode(snapshot)) as Map; + ((root['lots'] as List).single as Map)['type'] = + 'hologram'; + ((root['movements'] as List).single as Map)['type'] = + 'teleported'; + final decoded = codec.decode(jsonEncode(root)); + expect(decoded.lots.single.type, LotType.seed); // safe default + expect(decoded.movements, isEmpty); // no safe default → dropped + }); + + test('a broken row is skipped without aborting the import', () { + final root = jsonDecode(codec.encode(snapshot)) as Map; + (root['varieties'] as List).add({'id': 'var-2'}); // missing fields + final decoded = codec.decode(jsonEncode(root)); + expect(decoded.varieties, [variety]); + }); + + test('a newer formatVersion is rejected with a clear error', () { + final root = jsonDecode(codec.encode(snapshot)) as Map; + root['formatVersion'] = inventoryFormatVersion + 1; + expect(() => codec.decode(jsonEncode(root)), throwsFormatException); + }); + + test('malformed input is rejected', () { + expect(() => codec.decode('not json'), throwsFormatException); + expect(() => codec.decode('[1,2]'), throwsFormatException); + expect(() => codec.decode('{"foo": 1}'), throwsFormatException); + }); +} diff --git a/apps/app_seeds/test/data/export_import/inventory_round_trip_test.dart b/apps/app_seeds/test/data/export_import/inventory_round_trip_test.dart new file mode 100644 index 0000000..7fcd1bd --- /dev/null +++ b/apps/app_seeds/test/data/export_import/inventory_round_trip_test.dart @@ -0,0 +1,231 @@ +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/inventory_json_codec.dart'; +import 'package:tane/data/species_repository.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; + +import '../../support/test_support.dart'; + +void main() { + const codec = InventoryJsonCodec(); + const tomato = SpeciesSeed( + scientificName: 'Solanum lycopersicum', + family: 'Solanaceae', + ); + + late AppDatabase sourceDb; + late VarietyRepository source; + + setUp(() { + sourceDb = newTestDatabase(); + source = newTestRepository(sourceDb, nodeId: 'node-source'); + }); + tearDown(() => sourceDb.close()); + + /// Populates the source DB with one of everything and returns the ids. + Future<({String varietyId, String lotId})> populate() async { + await newTestSpeciesRepository(sourceDb).seedBundled(const [tomato]); + final varietyId = await source.addQuickVariety( + label: 'Grandma tomato', + category: 'Solanaceae', + photoBytes: Uint8List.fromList([10, 20, 30]), + ); + final species = await sourceDb.select(sourceDb.species).getSingle(); + await source.linkSpecies(varietyId, species.id); + await source.updateVariety( + id: varietyId, + label: 'Grandma tomato', + category: 'Solanaceae', + notes: 'Sow in March', + ); + final lotId = await source.addLot( + varietyId: varietyId, + harvestYear: 2024, + harvestMonth: 6, + quantity: const Quantity(kind: QuantityKind.cob, count: 2), + storageLocation: 'fridge', + ); + await source.addVernacularName( + varietyId, + 'tomate de la abuela', + language: 'es', + ); + await source.addExternalLink( + varietyId, + 'https://example.org/tomato', + title: 'Wiki', + ); + await source.addGerminationTest( + lotId: lotId, + testedOn: 1700000000000, + sampleSize: 10, + germinatedCount: 8, + ); + // A second photo, then promote it to cover so sortOrder must survive. + final cover = await source.addPhoto( + varietyId, + Uint8List.fromList([40, 50, 60]), + ); + await source.setPreferredPhoto(varietyId, cover); + return (varietyId: varietyId, lotId: lotId); + } + + test( + 'export → import into a fresh database restores the identical state', + () async { + await populate(); + final json = codec.encode(await source.exportInventory()); + + final targetDb = newTestDatabase(); + addTearDown(targetDb.close); + // The target install has its own bundled catalog (different species id). + await newTestSpeciesRepository(targetDb).seedBundled(const [tomato]); + final target = newTestRepository(targetDb, nodeId: 'node-target'); + + final summary = await target.importInventory(codec.decode(json)); + expect(summary.inserted, greaterThan(0)); + expect(summary.updated, 0); + + // Everything round-trips identically, except speciesId which is + // re-resolved against the target's own catalog by scientific name. + final sourceRows = await source.exportInventory(); + final targetRows = await target.exportInventory(); + expect( + targetRows.varieties.map((v) => v.copyWith(speciesId: const Value(null))), + sourceRows.varieties.map((v) => v.copyWith(speciesId: const Value(null))), + ); + expect(targetRows.lots, sourceRows.lots); + expect(targetRows.vernacularNames, sourceRows.vernacularNames); + expect(targetRows.externalLinks, sourceRows.externalLinks); + expect(targetRows.germinationTests, sourceRows.germinationTests); + expect(targetRows.movements, sourceRows.movements); + expect(targetRows.attachments, sourceRows.attachments); + + // The species link points at the target's own catalog row. + final targetSpecies = await targetDb.select(targetDb.species).getSingle(); + expect(targetRows.varieties.single.speciesId, targetSpecies.id); + + // Photo bytes and cover order survived. + final detail = await target.watchVariety( + targetRows.varieties.single.id, + ).first; + expect(detail!.photos.first.bytes, [40, 50, 60]); + expect(detail.photos.last.bytes, [10, 20, 30]); + }, + ); + + test('re-importing the same file is a no-op (idempotent)', () async { + await populate(); + final json = codec.encode(await source.exportInventory()); + final snapshot = codec.decode(json); + + final targetDb = newTestDatabase(); + addTearDown(targetDb.close); + final target = newTestRepository(targetDb, nodeId: 'node-target'); + + await target.importInventory(snapshot); + final again = await target.importInventory(codec.decode(json)); + expect(again.inserted, 0); + expect(again.updated, 0); + expect(again.skipped, greaterThan(0)); + + final varieties = await targetDb.select(targetDb.varieties).get(); + expect(varieties, hasLength(1)); // no duplicates + }); + + test('import does not overwrite local edits that are newer', () async { + final ids = await populate(); + final json = codec.encode(await source.exportInventory()); + + // The local edit happens after the export was taken. + await source.updateVariety( + id: ids.varietyId, + label: 'Renamed after export', + category: 'Solanaceae', + notes: 'Sow in March', + ); + + final summary = await source.importInventory(codec.decode(json)); + expect(summary.updated, 0); + + final variety = await (sourceDb.select( + sourceDb.varieties, + )..where((v) => v.id.equals(ids.varietyId))).getSingle(); + expect(variety.label, 'Renamed after export'); + }); + + test('import overwrites when the incoming row is newer (LWW)', () async { + final ids = await populate(); + + // Fork: a second device imports this inventory, edits the variety… + final json = codec.encode(await source.exportInventory()); + final otherDb = newTestDatabase(); + addTearDown(otherDb.close); + final other = newTestRepository(otherDb, nodeId: 'node-other'); + await other.importInventory(codec.decode(json)); + await other.updateVariety( + id: ids.varietyId, + label: 'Edited elsewhere', + category: 'Solanaceae', + notes: 'Sow in March', + ); + + // …and its export comes back: the newer edit wins here. + final back = codec.encode(await other.exportInventory()); + final summary = await source.importInventory(codec.decode(back)); + expect(summary.updated, greaterThan(0)); + + final variety = await (sourceDb.select( + sourceDb.varieties, + )..where((v) => v.id.equals(ids.varietyId))).getSingle(); + expect(variety.label, 'Edited elsewhere'); + expect(variety.lastAuthor, 'node-other'); + }); + + test('after an import the local clock never stamps behind it', () async { + await populate(); + final json = codec.encode(await source.exportInventory()); + final snapshot = codec.decode(json); + + // A target whose wall clock is frozen in the past. + final targetDb = newTestDatabase(); + addTearDown(targetDb.close); + final target = VarietyRepository( + targetDb, + idGen: IdGen(), + nodeId: 'node-past', + nowMillis: () => 1000, + ); + await target.importInventory(snapshot); + await target.addQuickVariety(label: 'Written after import'); + + final maxImported = snapshot.varieties + .map((v) => Hlc.parse(v.updatedAt)) + .reduce((a, b) => a.compareTo(b) > 0 ? a : b); + final written = await (targetDb.select( + targetDb.varieties, + )..where((v) => v.label.equals('Written after import'))).getSingle(); + expect( + Hlc.parse(written.updatedAt).compareTo(maxImported), + greaterThan(0), + ); + }); + + test('unmatched species leaves speciesId null instead of dangling', () async { + await populate(); + final json = codec.encode(await source.exportInventory()); + + final targetDb = newTestDatabase(); + addTearDown(targetDb.close); + // No bundled catalog in the target: the name cannot be resolved. + final target = newTestRepository(targetDb, nodeId: 'node-target'); + await target.importInventory(codec.decode(json)); + + final variety = await targetDb.select(targetDb.varieties).getSingle(); + expect(variety.speciesId, isNull); + }); +} diff --git a/apps/app_seeds/test/ui/backup_section_test.dart b/apps/app_seeds/test/ui/backup_section_test.dart new file mode 100644 index 0000000..4499b5f --- /dev/null +++ b/apps/app_seeds/test/ui/backup_section_test.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/inventory_json_codec.dart'; +import 'package:tane/data/export_import/inventory_snapshot.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/export_import_service.dart'; +import 'package:tane/services/file_service.dart'; +import 'package:tane/ui/backup_section.dart'; +import 'package:tane/ui/settings_screen.dart'; + +import '../support/test_support.dart'; + +/// Records calls instead of opening real dialogs. +class FakeFileService implements FileService { + String? savedName; + Uint8List? savedBytes; + Uint8List? bytesToPick; + + @override + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async { + savedName = suggestedName; + savedBytes = bytes; + return '/picked/$suggestedName'; + } + + @override + Future pickFileBytes({List? allowedExtensions}) async => + bytesToPick; +} + +void main() { + late AppDatabase db; + late FakeFileService files; + late ExportImportService service; + + setUp(() { + db = newTestDatabase(); + files = FakeFileService(); + service = ExportImportService( + repository: newTestRepository(db), + files: files, + ); + }); + tearDown(() => db.close()); + + Widget wrap(Widget child) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ), + ); + } + + testWidgets('settings shows the backup section with its three actions', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(wrap(SettingsScreen(exportImport: service))); + + expect(find.text('Backup'), findsOneWidget); + expect(find.text('Export as CSV'), findsOneWidget); + expect(find.text('Export as JSON'), findsOneWidget); + expect(find.text('Import from JSON'), findsOneWidget); + }); + + testWidgets('export CSV saves a .csv file and confirms', (tester) async { + await tester.pumpWidget( + wrap(Scaffold(body: BackupSection(service: service))), + ); + await tester.tap(find.text('Export as CSV')); + await tester.pumpAndSettle(); + + expect(files.savedName, endsWith('.csv')); + expect(utf8.decode(files.savedBytes!), startsWith('variety_id,')); + expect(find.text('Copy saved'), findsOneWidget); + }); + + testWidgets('export JSON saves a versioned .json file', (tester) async { + await tester.pumpWidget( + wrap(Scaffold(body: BackupSection(service: service))), + ); + await tester.tap(find.text('Export as JSON')); + await tester.pumpAndSettle(); + + expect(files.savedName, endsWith('.json')); + final root = + jsonDecode(utf8.decode(files.savedBytes!)) as Map; + expect(root['formatVersion'], inventoryFormatVersion); + expect(find.text('Copy saved'), findsOneWidget); + }); + + testWidgets('import asks for confirmation, imports and reports counts', ( + tester, + ) async { + // A valid one-variety export produced by another repository. + final otherDb = newTestDatabase(); + addTearDown(otherDb.close); + final other = newTestRepository(otherDb, nodeId: 'node-other'); + await other.addQuickVariety(label: 'Imported bean'); + files.bytesToPick = utf8.encode( + const InventoryJsonCodec().encode(await other.exportInventory()), + ); + + await tester.pumpWidget( + wrap(Scaffold(body: BackupSection(service: service))), + ); + await tester.tap(find.text('Import from JSON')); + await tester.pumpAndSettle(); + expect(find.text('Import a saved copy?'), findsOneWidget); + + await tester.tap(find.text('Import')); + await tester.pumpAndSettle(); + + expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget); + final varieties = await db.select(db.varieties).get(); + expect(varieties.single.label, 'Imported bean'); + }); + + testWidgets('an unreadable file reports a friendly error', (tester) async { + files.bytesToPick = utf8.encode('this is not json'); + await tester.pumpWidget( + wrap(Scaffold(body: BackupSection(service: service))), + ); + await tester.tap(find.text('Import from JSON')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Import')); + await tester.pumpAndSettle(); + + expect( + find.text('This file could not be read as a Tanemaki copy'), + findsOneWidget, + ); + }); +} diff --git a/docs/design/data-model.md b/docs/design/data-model.md index 6340348..7016f16 100644 --- a/docs/design/data-model.md +++ b/docs/design/data-model.md @@ -226,3 +226,20 @@ Together: Drift handles the *local database* migration; the compatibility rules - Is `offer_status` on `Lot` enough, or does a `Variety`-level default help? (Lean: Lot only.) - Do we need `Variety`-level provenance, or is per-Lot/Movement provenance sufficient? (Lean: Movement DAG is enough.) - `category` as free enum vs linked to `Species.family` when a species is set. (Lean: free, prefilled from family when available.) + +## 7. Interchange export/import (Phase 1) — decided & implemented + +The user-triggered **interchange export** (Settings → Backup). Distinct from the future encrypted full backup (`.tanemaki`, [backup-and-recovery.md](backup-and-recovery.md) mechanism 1): the interchange files are the *only* plaintext the app ever writes, and only because the user explicitly asks for them. Import reads the picked file straight into memory — no temp copies. + +Two formats, one canonical: + +- **JSON** — canonical, versioned, importable; a precursor of the future sync payload. Envelope: `formatVersion` (currently 1), `schemaVersion` (the writer's DB generation), `appId: "tane"`, then one array per table: `varieties`, `lots`, `vernacularNames`, `externalLinks`, `germinationTests`, `movements`, `parties`, `attachments`. Every row carries its sync metadata verbatim (`id`, `createdAt`, packed-HLC `updatedAt`, `lastAuthor`, `schemaRowVersion`); enums serialize by name. **Photos/docs are embedded as base64** (`bytesBase64`) — the JSON export is the complete copy of the data (~33% size overhead accepted). **Tombstones are excluded**: this is a user-facing export of the live inventory, not the sync wire format (real sync, Block 2, will carry tombstones on its own channel). +- **CSV** — export-only, for spreadsheets. One row per Lot (a variety with no lots gets one row); vernacular names and links joined into single columns; latest germination rate derived; ISO-8601 dates; never any photo bytes. Not importable — the JSON is. + +**Species are not exported.** The bundled catalog rows get per-install ids, so `species_id` is not portable. Each exported variety carries a denormalized `speciesScientificName`; the importer re-resolves it against its own catalog by scientific name (no match → `species_id` stays null). + +**Import = merge by id (UUIDv7), never duplicating.** Mutable rows: unknown id → insert preserving the original metadata (no re-stamping); known id → last-writer-wins on the packed HLC `updatedAt` (§4), incoming wins only if strictly newer — so re-importing the same file is a no-op, and a locally-deleted row only resurrects if the incoming version is newer than the tombstone. `movements` are append-only: insert-if-unknown, never updated. Everything runs in one transaction; afterwards the local HLC clock `receiveEvent`s the newest imported stamp so the next local write out-stamps everything imported. + +**Reader compatibility (per §5.2):** unknown JSON fields are ignored; unknown enum values map to a safe default (`lot.type → seed`, `offer_status → private`) or drop the row when none exists (an unknown `movement.type`); a `formatVersion` newer than the reader supports fails with a clear "update the app" error. `formatVersion` changes must be additive and documented here. + +Implementation: `apps/app_seeds/lib/data/export_import/` (codecs + reconciler), `VarietyRepository.exportInventory()/importInventory()`, `lib/services/export_import_service.dart`. diff --git a/pubspec.lock b/pubspec.lock index 42b246f..6c803a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" drift: dependency: transitive description: @@ -281,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" file_selector_linux: dependency: transitive description: