feat(inventory): additive CSV import for bulk digitization

Import a spreadsheet list (the same 21-column schema the CSV export
already produces) as new varieties/lots. Tolerant reader: columns
matched by header name in any order, only variety_label required,
unknown enums fall back to a safe default, rows sharing variety_id
collapse into one variety with several lots. Species re-linked by
scientific name. Additive by design (fresh ids + HLC), so it never
overwrites existing rows; JSON stays the canonical id-reconciling import.

Adds a hand-rolled RFC 4180 parser (no new dependency), an importCsv
path in VarietyRepository/ExportImportService, an 'Import from CSV'
tile in the backup section, and en/es strings.

Note: i18n .g.dart also carries pre-existing About/intro strings from
the working tree (regenerated by slang).
This commit is contained in:
vjrj 2026-07-09 14:41:28 +02:00
parent 21072633b5
commit 89b61bc9e4
11 changed files with 1070 additions and 2 deletions

View file

@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart';
/// A lightweight row for the inventory list (only what the list renders).
@ -961,6 +962,113 @@ class VarietyRepository {
return summary;
}
/// Additive import of a spreadsheet [csv]: every parsed variety is inserted
/// fresh (new UUIDv7 + local HLC), so it never overwrites existing rows and
/// re-importing the same file adds it again. The canonical, id-reconciling
/// import is JSON ([importInventory]); this is the low-friction path for a
/// list a collective already keeps in a spreadsheet.
///
/// Species are re-linked by scientific name against the local catalog
/// (unmatched null). Returns the number of varieties added.
Future<ImportSummary> importCsv(CsvImport csv) async {
var inserted = 0;
await _db.transaction(() async {
for (final v in csv.varieties) {
final speciesId = await _resolveSpeciesByName(v.scientificName);
final varietyId = idGen.newId();
final (created, updated) = _stamp();
await _db
.into(_db.varieties)
.insert(
VarietiesCompanion.insert(
id: varietyId,
label: v.label,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
category: Value(v.category),
cultivarName: Value(v.cultivarName),
notes: Value(v.notes),
speciesId: Value(speciesId),
),
);
inserted++;
for (final lot in v.lots) {
final (created, updated) = _stamp();
await _db
.into(_db.lots)
.insert(
LotsCompanion.insert(
id: idGen.newId(),
varietyId: varietyId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
type: Value(lot.type),
harvestYear: Value(lot.harvestYear),
harvestMonth: Value(lot.harvestMonth),
quantityKind: Value(lot.quantityKind),
quantityPrecise: Value(lot.quantityPrecise),
quantityLabel: Value(lot.quantityLabel),
presentation: Value(lot.presentation),
storageLocation: Value(lot.storageLocation),
offerStatus: Value(lot.offerStatus),
),
);
}
for (final name in v.vernacularNames) {
final (created, updated) = _stamp();
await _db
.into(_db.varietyVernacularNames)
.insert(
VarietyVernacularNamesCompanion.insert(
id: idGen.newId(),
varietyId: varietyId,
name: name.name,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
language: Value(name.language),
),
);
}
for (final link in v.links) {
final (created, updated) = _stamp();
await _db
.into(_db.externalLinks)
.insert(
ExternalLinksCompanion.insert(
id: idGen.newId(),
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
parentType: ParentType.variety,
parentId: varietyId,
url: link.url,
title: Value(link.title),
),
);
}
}
});
return ImportSummary(inserted: inserted);
}
/// Resolves a single scientific name to a local catalog species id, or null
/// when blank or unmatched.
Future<String?> _resolveSpeciesByName(String? scientificName) async {
final name = scientificName?.trim();
if (name == null || name.isEmpty) return null;
final local = await (_db.select(_db.species)..where(
(s) => s.scientificName.equals(name) & s.isDeleted.equals(false),
))
.getSingleOrNull();
return local?.id;
}
/// Maps incoming species ids to local catalog ids by scientific name.
Future<Map<String, String?>> _resolveSpecies(
Map<String, String> speciesNamesById,