import 'package:commons_core/commons_core.dart'; import 'package:drift/drift.dart'; import '../db/database.dart'; import '../domain/species_autoclassify.dart'; /// One entry from the bundled catalog, before it is stored. class SpeciesSeed { const SpeciesSeed({ required this.scientificName, this.family, this.wikidataQid, this.gbifKey, this.commonNames = const {}, this.viabilityYears, }); final String scientificName; final String? family; /// Wikidata entity id (e.g. `Q13223`), the CC0 source of this row; null when /// the catalog was authored by hand. Kept for de-duplication and to feed the /// derived Wikipedia (locale-aware) reference link. final String? wikidataQid; /// GBIF backbone taxon key; null if unknown. Feeds the derived reference link. final int? gbifKey; /// language code โ†’ list of common names. final Map> commonNames; /// Typical seed longevity in years (bundled reference data); null if unknown. final int? viabilityYears; } /// A catalog match surfaced to the UI (with a best common name for the locale). class SpeciesMatch { const SpeciesMatch({ required this.id, required this.scientificName, this.family, this.commonName, }); final String id; final String scientificName; final String? family; final String? commonName; /// Label shown in the autocomplete: common name (scientific) when both exist. String get displayLabel => commonName == null ? scientificName : '$commonName ($scientificName)'; } /// Reads and seeds the bundled species catalog. Bundled rows are marked /// `is_bundled = true` and are not synced (data-model ยง2.2). class SpeciesRepository { SpeciesRepository(this._db, {required this.idGen, this.nodeId = 'bundle'}); final AppDatabase _db; final IdGen idGen; final String nodeId; /// Idempotently inserts bundled species (keyed by scientific name). Safe to /// call on every startup. Existing rows keep their identity, but bundled /// reference fields still missing on a row ([SpeciesSeed.viabilityYears], /// [SpeciesSeed.wikidataQid], [SpeciesSeed.gbifKey]) are backfilled โ€” so data /// added to the catalog reaches installs seeded before the field was bundled. /// /// Reads all existing species once and writes in a single batch: with a /// catalog of ~1000 species a per-row SELECT on every startup would be a real /// cost on the encrypted database. Future seedBundled(List seeds) async { final stamp = Hlc.zero(nodeId).pack(); await _db.transaction(() async { final existing = { for (final s in await _db.select(_db.species).get()) s.scientificName: s, }; final newSpecies = []; final newNames = []; final backfills = <({String id, SpeciesCompanion patch})>[]; for (final seed in seeds) { final row = existing[seed.scientificName]; if (row != null) { final patch = _backfillPatch(row, seed); if (patch != null) backfills.add((id: row.id, patch: patch)); continue; } final speciesId = idGen.newId(); newSpecies.add( SpeciesCompanion.insert( id: speciesId, createdAt: 0, updatedAt: stamp, lastAuthor: nodeId, scientificName: seed.scientificName, family: Value(seed.family), wikidataQid: Value(seed.wikidataQid), gbifKey: Value(seed.gbifKey), isBundled: const Value(true), viabilityYears: Value(seed.viabilityYears), ), ); for (final entry in seed.commonNames.entries) { for (final name in entry.value) { newNames.add( SpeciesCommonNamesCompanion.insert( id: idGen.newId(), createdAt: 0, updatedAt: stamp, lastAuthor: nodeId, speciesId: speciesId, name: name, language: Value(entry.key), ), ); } } } if (newSpecies.isEmpty && newNames.isEmpty && backfills.isEmpty) return; await _db.batch((b) { b.insertAll(_db.species, newSpecies); b.insertAll(_db.speciesCommonNames, newNames); for (final entry in backfills) { b.update( _db.species, entry.patch, where: (s) => s.id.equals(entry.id), ); } }); }); } /// A partial update carrying only the bundled reference fields a stored row is /// still missing, or null when the row is already complete. SpeciesCompanion? _backfillPatch(Specy row, SpeciesSeed seed) { var patch = const SpeciesCompanion(); var changed = false; if (row.viabilityYears == null && seed.viabilityYears != null) { patch = patch.copyWith(viabilityYears: Value(seed.viabilityYears)); changed = true; } if (row.wikidataQid == null && seed.wikidataQid != null) { patch = patch.copyWith(wikidataQid: Value(seed.wikidataQid)); changed = true; } if (row.gbifKey == null && seed.gbifKey != null) { patch = patch.copyWith(gbifKey: Value(seed.gbifKey)); changed = true; } return changed ? patch : null; } /// Searches species by scientific name or a common name (case-insensitive /// substring). Filtering runs in SQL (`LIKE`) so a large catalog is not pulled /// into memory on every keystroke. Returns up to [limit] matches, each with a /// best common name for [languageCode]. Future> search( String query, { String languageCode = 'en', int limit = 8, }) async { final q = query.trim(); if (q.isEmpty) return const []; // SQLite `LIKE` is case-insensitive for ASCII (matching the previous // `toLowerCase().contains` behaviour); `%`/`_`/`\` in the query are escaped // so they match literally rather than acting as wildcards. final pattern = '%${_escapeLike(q)}%'; // A few extra candidates so the final scientific-name sort/limit is stable. final scan = limit * 4; final byScientific = await (_db.select(_db.species) ..where( (s) => s.isDeleted.equals(false) & s.scientificName.like(pattern, escapeChar: r'\'), ) ..limit(scan)) .get(); final byCommon = await (_db.select(_db.speciesCommonNames) ..where( (n) => n.isDeleted.equals(false) & n.name.like(pattern, escapeChar: r'\'), ) ..limit(scan)) .get(); final speciesById = {for (final s in byScientific) s.id: s}; final missing = byCommon .map((n) => n.speciesId) .where((id) => !speciesById.containsKey(id)) .toSet(); if (missing.isNotEmpty) { final more = await (_db.select(_db.species)..where( (s) => s.isDeleted.equals(false) & s.id.isIn(missing.toList()), )) .get(); for (final s in more) { speciesById[s.id] = s; } } if (speciesById.isEmpty) return const []; // Resolve a localized label only for the matched species. final names = await (_db.select(_db.speciesCommonNames)..where( (n) => n.isDeleted.equals(false) & n.speciesId.isIn(speciesById.keys.toList()), )) .get(); final namesBySpecies = >{}; for (final n in names) { namesBySpecies.putIfAbsent(n.speciesId, () => []).add(( name: n.name, language: n.language, )); } final matches = [ for (final s in speciesById.values) SpeciesMatch( id: s.id, scientificName: s.scientificName, family: s.family, commonName: _bestName(namesBySpecies[s.id] ?? const [], languageCode), ), ]; matches.sort((a, b) => a.scientificName.compareTo(b.scientificName)); return matches.take(limit).toList(); } /// Escapes SQL `LIKE` metacharacters so a user query matches literally. String _escapeLike(String input) => input .replaceAll(r'\', r'\\') .replaceAll('%', r'\%') .replaceAll('_', r'\_'); /// Auto-classification for the UI: infers the single species a free-text /// variety [label] names (see [matchSpeciesInLabel]) and returns it as a /// [SpeciesMatch] with a best common name for [languageCode]. Returns null /// when the label names no known species or the match is ambiguous โ€” so the /// caller can offer it as a one-tap suggestion, never a silent guess. Future classifyLabel( String label, { String languageCode = 'en', }) async { final species = await (_db.select( _db.species, )..where((s) => s.isDeleted.equals(false))).get(); final commons = await (_db.select( _db.speciesCommonNames, )..where((n) => n.isDeleted.equals(false))).get(); final names = [ for (final s in species) SpeciesNameEntry(speciesId: s.id, name: s.scientificName), for (final c in commons) SpeciesNameEntry(speciesId: c.speciesId, name: c.name), ]; final id = matchSpeciesInLabel(label, names); if (id == null) return null; final match = species.firstWhere((s) => s.id == id); final matchNames = [ for (final c in commons) if (c.speciesId == id) (name: c.name, language: c.language), ]; return SpeciesMatch( id: match.id, scientificName: match.scientificName, family: match.family, commonName: _bestName(matchNames, languageCode), ); } String? _bestName( List<({String name, String? language})> names, String languageCode, ) { if (names.isEmpty) return null; final inLocale = names.where((n) => n.language == languageCode); return (inLocale.isNotEmpty ? inLocale.first : names.first).name; } }