/// Auto-classification: infer the species a free-text variety label refers to, /// so typing "Maíz de la abuela" can link *Zea mays* (and prefill the category) /// without the grower opening the species picker. /// /// Pure domain logic (no DB, no Flutter) so it is trivially unit-testable. The /// repository feeds it the bundled catalog names; matching is done here. library; /// One catalog name that can identify a species: a common (vernacular) name or /// the scientific name, paired with the species it belongs to. The matcher is /// language-agnostic — it never assumes es/en — so the repository passes names /// from every bundled locale at once. class SpeciesNameEntry { const SpeciesNameEntry({required this.speciesId, required this.name}); final String speciesId; final String name; } /// Detects the species a free-text variety [label] refers to by matching a /// bundled [names] entry as a whole word inside the label — accent- and /// case-insensitively. "Maíz de la abuela" → *Zea mays*; "Tomate cherry" → /// *Solanum lycopersicum*. /// /// Returns the matched species id, or null when nothing matches or the best /// match is ambiguous. The rules keep it from mislabelling: /// * **Whole-word (token) match only**, so "pea" never fires on "peach" nor /// "haba" on "habanero" — a common name must appear as its own word(s). /// * **Longest name wins** (most specific): "broad bean" beats "bean". /// * **Ambiguous → null**: if the winning name maps to more than one species /// (e.g. "calabaza" spanning several squashes), it is left for the grower /// to disambiguate rather than guessed. /// /// A light trailing-`s` fold lets a plural label word ("Tomates", "Habas") /// still match a singular catalog name. It is an additive nudge for the many /// languages that pluralise with `-s`, never an override of an exact match, and /// it cannot introduce a match that a different word did not already carry. String? matchSpeciesInLabel(String label, List names) { final haystack = _tokenize(label); if (haystack.isEmpty) return null; ({int tokens, int chars})? best; final winners = {}; for (final entry in names) { final needle = _tokenize(entry.name); if (!_containsSequence(haystack, needle)) continue; final chars = needle.fold(0, (sum, t) => sum + t.length); final score = (tokens: needle.length, chars: chars); if (best == null || _isBetter(score, best)) { best = score; winners ..clear() ..add(entry.speciesId); } else if (score.tokens == best.tokens && score.chars == best.chars) { winners.add(entry.speciesId); } } // A single species behind the best-scoring name(s) → confident; otherwise the // name is shared across species, so leave it unclassified. return winners.length == 1 ? winners.first : null; } bool _isBetter(({int tokens, int chars}) a, ({int tokens, int chars}) b) => a.tokens > b.tokens || (a.tokens == b.tokens && a.chars > b.chars); /// Lowercases, splits on any non-letter/non-digit boundary (Unicode-aware, so /// non-Latin scripts survive), and folds Latin diacritics on each token. List _tokenize(String s) => s .toLowerCase() .split(_wordBoundary) .where((t) => t.isNotEmpty) .map(_foldDiacritics) .toList(growable: false); final RegExp _wordBoundary = RegExp(r'[^\p{L}\p{N}]+', unicode: true); /// True when [needle] appears as a contiguous run of words inside [haystack]. bool _containsSequence(List haystack, List needle) { if (needle.isEmpty || needle.length > haystack.length) return false; for (var i = 0; i + needle.length <= haystack.length; i++) { var matched = true; for (var j = 0; j < needle.length; j++) { if (!_wordsEqual(haystack[i + j], needle[j])) { matched = false; break; } } if (matched) return true; } return false; } bool _wordsEqual(String a, String b) => a == b || _singular(a) == _singular(b); /// Drops a single trailing `s` from words longer than three letters — a /// deliberately crude plural fold (see [matchSpeciesInLabel]). String _singular(String t) => (t.length > 3 && t.endsWith('s')) ? t.substring(0, t.length - 1) : t; /// Folds common Latin diacritics to their base letter so "Maiz" matches "Maíz". /// Only Latin marks are folded; other scripts pass through untouched, keeping /// the matcher usable for any language. String _foldDiacritics(String token) { if (token.codeUnits.every((c) => c < 0x80)) return token; // fast path: ASCII final buffer = StringBuffer(); for (final ch in token.split('')) { buffer.write(_diacriticFolds[ch] ?? ch); } return buffer.toString(); } const Map _diacriticFolds = { 'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ă': 'a', 'ą': 'a', 'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e', 'ě': 'e', 'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i', 'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o', 'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u', 'ñ': 'n', 'ń': 'n', 'ň': 'n', 'ç': 'c', 'ć': 'c', 'č': 'c', 'ş': 's', 'š': 's', 'ș': 's', 'ś': 's', 'ž': 'z', 'ź': 'z', 'ż': 'z', 'ý': 'y', 'ÿ': 'y', 'ğ': 'g', 'ł': 'l', 'ð': 'd', 'þ': 'th', 'ß': 'ss', 'œ': 'oe', 'æ': 'ae', };