Infer the catalog species a free-text variety label names ("Maiz de la
abuela" -> Zea mays) and prefill the category from the species family.
- Pure, testable matcher (domain/species_autoclassify.dart): whole-word,
accent/case-insensitive, Unicode-aware (any script), longest-name-wins,
ambiguous names left unclassified, light plural fold.
- Quick-add and draft naming auto-link the species when the field is empty
(non-destructive; an explicit category is kept).
- Edit sheet offers a one-tap suggestion from the typed name.
Tests: matcher unit, repository integration, SpeciesRepository.classifyLabel,
and an edit-sheet widget test.
299 lines
10 KiB
Dart
299 lines
10 KiB
Dart
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<String, List<String>> 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<void> seedBundled(List<SpeciesSeed> 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 = <SpeciesCompanion>[];
|
|
final newNames = <SpeciesCommonNamesCompanion>[];
|
|
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<List<SpeciesMatch>> 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 = <String, List<({String name, String? language})>>{};
|
|
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<SpeciesMatch?> 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 = <SpeciesNameEntry>[
|
|
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;
|
|
}
|
|
}
|