Module: Vendor::Colrapi::NameTranslation
- Defined in:
- lib/vendor/colrapi/name_translation.rb
Overview
Translates between how the Catalogue of Life writes a name and how TaxonWorks stores its names.
CoL's formatting depends on the source dataset (sector) a name came from, and isn't always consistent within one source. What has been seen so far (live CoL, 3LR, 2026-09):
* subgenus: "Genus (Subgenus)" for zoological names, in search
results and classifications, but "Genus subgen. Subgenus" for
botanical ones (all 183 botanical subgenera in the backbone, every
one a synonym, 2026-10). TaxonWorks stores the bare epithet in #name
and "Genus (Subgenus)" in #cached (ICZN and ICN alike). The
parentheses are ICZN's rule (Art. 6.1). The ICN writes a subgenus
"Genus subg. Epithet" (Art. 21.1) and only recommends parentheses
(Rec. 21A.1) for showing it inside a species name.
* species group: some sources include the subgenus ("Aedes (Stegomyia)
aegypti", mosquitoes), some omit it ("Bombus terrestris", bees), and
one source can do both (Carabus: "Carabus (Megodontus) violaceus" but
"Carabus violaceus violaceus"). TaxonWorks #cached includes the
subgenus whenever the name is placed under one.
* synonyms can differ from their accepted name within one source: WAD
gives accepted "Aphrophora (Aphrophora) stictica" with the subgenus,
its synonym "Aphrophora binomoriensis" without it (2026-10).
* a name's original combination can be listed as a synonym of the
same species, differing only by subgenus ("Aphrophora ambigua" ->
"Aphrophora (Aphrophora) ambigua", WAD). So a CoL synonym whose
accepted name is the same name once the subgenus is ignored (both
have the same comparison key, see below) doesn't mean a TaxonWorks
name matching it is outdated.
* searching, CoL's /nameusage/search (Colrapi.nameusage_search, used
by .search_term's callers): a query including the subgenus doesn't
return a name CoL gives without it ("Aphrophora (Aphrophora)
binomoriensis" misses "Aphrophora binomoriensis"), while a query
without it still returns names CoL gives with it ("Aedes aegypti"
finds "Aedes (Stegomyia) aegypti", "Aphrophora ambigua" finds both
forms) - see .search_term.
!! The single query in .search_term depends on that second half: it's
observed CoL search behavior (2026-10), not documented. If CoL ever
stops matching that way, names CoL gives with a subgenus silently go
missing (e.g. Outdated Names reports them :undeterminable) and specs,
which stub CoL, won't notice - a second query including the subgenus
would then be needed.
* looking up, CoL's /nameusage?q= (Vendor::Colrapi.search, the
autoselect levels): matches only the exact string, so neither form
finds the other ("Aedes aegypti" finds nothing).
* ICN infraspecific names include connecting terms ("Olea europaea
subsp. africana"), same as TaxonWorks #cached.
* [sic] is TaxonWorks-only (#cached), CoL doesn't send it.
Names are compared by a "key" computed the same way from both sides:
- col_name_key - for a name as CoL gives it
- taxonworks_name_keys - for each form of a TaxonName (see .forms)
- name_key - for a naked/unassociated name The key is never displayed or used to create anything. (.search_term, what /nameusage/search is queried with, is close to it, but keeps a subgenus's whole name.)
[Authorship is compared the same way, by Utilities::AuthorNames.authorship_key.]
Asks of CoL - things CoL's API should handle rather than TaxonWorks working around them. What we do in the meantime:
* /nameusage/search should find a name with or without its
subgenus, both ways. A query with the subgenus misses names CoL
gives without one, so we search without it (.search_term) -
except for a subgenus itself. (The exact /nameusage?q= lookup is
exact by design - not an ask.)
* a name should come formatted the same way whatever its source
(subgenus included or not, a synonym like its accepted name). We
compare keys that ignore the subgenus.
* search should accept "Genus (Subgenus)" for a botanical subgenus,
not only "Genus subgen. Subgenus". Not worked around, see
Vendor::Colrapi.search.
* search should find a fossil name without its dagger (†). Not worked
around.
* the formats of ICN section, subsection, series, subseries (and ICZN
supersubgenus/infragenus) names should be documented - no CoL
examples were found. Not handled: TaxonWorks #cached for these is
just the genus, so #name would need to be compared.
* author/year formats should be consistent across sources (they
likely vary as names do).
* Seen so far (2026-10) they match TaxonWorks #cached_author_year;
see Utilities::AuthorNames.authorship_key for how author/year
are currently compared across CoL and TW.
Defined Under Namespace
Classes: Candidate
Constant Summary collapse
- SUBGENUS_NAME =
A whole subgenus name: "Genus (Subgenus)" (CoL zoological, and TaxonWorks #cached for any code) or "Genus subgen. Subgenus" (CoL botanical). The epithet is in group 1 or 2.
/\A\S+ (?:\(([^)]+)\)|subgen\. (\S+))\z/
Class Method Summary collapse
-
.candidates_by_key(col_rows, project_id:) ⇒ Hash{String => Array<Candidate>}
Finds the TaxonNames in project_id one of whose .forms has the key of one of col_rows - which form matched is reported (a name whose two forms share a key is listed once, as :current).
-
.col_name_key(col_name, rank) ⇒ String?
The comparison key for col_name.
- .comparable(name) ⇒ String? private
-
.forms(taxon_name) ⇒ Hash
The two forms a TaxonWorks name can be written in, which CoL also uses: * current - #cached, the name as TaxonWorks places it now.
-
.name_key(name) ⇒ String?
The comparison key for a name given as text on the TaxonWorks side - use only when there's no TaxonName (else see .taxonworks_name_keys).
-
.protonym_name(col_name, rank) ⇒ String?
CoL classification entries give species-group names in full ("Olea europaea", "Carabus (Megodontus) violaceus", "Olea europaea subsp. cuspidata"), TaxonWorks Protonym#name takes just the last epithet.
-
.search_term(name) ⇒ String?
The string to search CoL's fuzzy /nameusage/search (::Colrapi.nameusage_search) with for a name (TaxonWorks #cached or free text) so that every form CoL may give it in comes back.
-
.split_authorship(col_authorship, col_year) ⇒ Object
Splits a CoL authorship string into [verbatim_author, year_of_publication].
-
.subgenus_epithet(col_name) ⇒ String?
TaxonWorks Protonym#name for a subgenus: just the epithet.
-
.taxonworks_name_keys(taxon_name) ⇒ Hash
The comparison key of each of a TaxonName's .forms (a subgenus by its epithet).
-
.without_subgenus(name) ⇒ String
private
Drops every parenthetical, not just a subgenus (an ICZN interpolated species-group name, Art. 6.2, goes too) - fine for a comparison key.
Class Method Details
.candidates_by_key(col_rows, project_id:) ⇒ Hash{String => Array<Candidate>}
Finds the TaxonNames in project_id one of whose .forms has the key of one of col_rows - which form matched is reported (a name whose two forms share a key is listed once, as :current). Callers still need to check rank (and code) themselves, and decide what a :published match means for them.
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 |
# File 'lib/vendor/colrapi/name_translation.rb', line 275 def self.candidates_by_key(col_rows, project_id:) keys = col_rows .map { |row| col_name_key(row[:col_name], row[:rank]) } .compact .uniq return {} if keys.empty? t = ::TaxonName.arel_table single_word, multi_word = keys.partition { |k| !k.include?(' ') } # Exact #cached catches most names (Combinations included); #name # catches a subgenus by its epithet, and a misspelled uninomial whose # #cached carries [sic]. condition = t[:cached].in(keys) .or(t[:cached_original_combination].in(keys)) condition = condition.or(t[:name].in(single_word)) if single_word.any? # A species-group name may carry a subgenus or [sic] the key doesn't: # match #cached on the last epithet (#name) within the genus, and # either form on "Genus ... epithet" (the plain "Genus epithet" is # matched exactly above). This finds a superset: anything can sit # between genus and last epithet, so "Aus bus cus" also finds # "Aus xus cus", and "Aus bus" finds "Aus xus bus"; the keys slice # below restricts back to applicable names to be returned. multi_word.each do |k| words = k.split(' ') genus = ::ActiveRecord::Base.sanitize_sql_like(words.first) epithet = ::ActiveRecord::Base.sanitize_sql_like(words.last) condition = condition .or(t[:name].eq(words.last).and(t[:cached].matches("#{genus} %"))) .or(t[:cached].matches("#{genus} % #{epithet}")) .or( t[:cached_original_combination].matches("#{genus} % #{epithet}") ) end candidates = Hash.new { |h, k| h[k] = [] } ::TaxonName.where(project_id:).where(condition).each do |taxon_name| current, published = taxonworks_name_keys(taxon_name) .values_at(:current, :published) if current candidates[current] << Candidate.new(taxon_name, :current) end if published && published != current candidates[published] << Candidate.new(taxon_name, :published) end end candidates.slice(*keys) end |
.col_name_key(col_name, rank) ⇒ String?
Returns the comparison key for col_name.
150 151 152 153 154 155 156 157 158 159 |
# File 'lib/vendor/colrapi/name_translation.rb', line 150 def self.col_name_key(col_name, rank) return nil if col_name.nil? if rank == 'subgenus' return subgenus_epithet( ::Utilities::Nomenclature.unmisspell_name(col_name) ) end comparable(col_name) end |
.comparable(name) ⇒ String? (private)
328 329 330 331 332 |
# File 'lib/vendor/colrapi/name_translation.rb', line 328 def self.comparable(name) return nil if name.nil? without_subgenus(::Utilities::Nomenclature.unmisspell_name(name)) end |
.forms(taxon_name) ⇒ Hash
The two forms a TaxonWorks name can be written in, which CoL also uses:
* current - #cached, the name as TaxonWorks places it now. Compare
it to CoL names in their current placement, e.g. CoL's accepted
classification.
* published - the name as published: a Protonym's original
combination, or a Combination itself (which has no current form
of its own). CoL lists synonyms this way, while TaxonWorks moves a
synonym under its valid name's parent, so a synonym's #cached can
be a combination that was never published.
Which to compare is the caller's decision, and depends on what it's comparing against.
245 246 247 248 249 250 251 252 253 254 |
# File 'lib/vendor/colrapi/name_translation.rb', line 245 def self.forms(taxon_name) if taxon_name.is_a?(::Combination) { current: nil, published: taxon_name.cached } else { current: taxon_name.cached, published: taxon_name.cached_original_combination.presence } end end |
.name_key(name) ⇒ String?
The comparison key for a name given as text on the TaxonWorks side - use only when there's no TaxonName (else see .taxonworks_name_keys).
166 167 168 169 170 171 172 173 |
# File 'lib/vendor/colrapi/name_translation.rb', line 166 def self.name_key(name) return nil if name.nil? name = ::Utilities::Nomenclature.unmisspell_name(name) return subgenus_epithet(name) if name.match?(SUBGENUS_NAME) comparable(name) end |
.protonym_name(col_name, rank) ⇒ String?
CoL classification entries give species-group names in full ("Olea europaea", "Carabus (Megodontus) violaceus", "Olea europaea subsp. cuspidata"), TaxonWorks Protonym#name takes just the last epithet. Names that are already an epithet or uninomial are returned as is.
140 141 142 143 144 145 |
# File 'lib/vendor/colrapi/name_translation.rb', line 140 def self.protonym_name(col_name, rank) return nil if col_name.nil? return subgenus_epithet(col_name) if rank == 'subgenus' col_name.split(' ').last end |
.search_term(name) ⇒ String?
The string to search CoL's fuzzy /nameusage/search (::Colrapi.nameusage_search) with for a name (TaxonWorks #cached or free text) so that every form CoL may give it in comes back.
!! One query covers both forms only because that search returns names with a subgenus for a query without one - observed, not documented; see the search note above before relying on this elsewhere. It's no use with the exact /nameusage?q= (Vendor::Colrapi.search): there a query without the subgenus misses names CoL gives with one.
A subgenus itself keeps its whole name: without the subgenus it would be a search for its genus, whose many subgenera can push the one wanted off the first page of results.
106 107 108 109 110 111 112 113 |
# File 'lib/vendor/colrapi/name_translation.rb', line 106 def self.search_term(name) return nil if name.nil? name = ::Utilities::Nomenclature.unmisspell_name(name) return name.strip if name.strip.match?(SUBGENUS_NAME) without_subgenus(name) end |
.split_authorship(col_authorship, col_year) ⇒ Object
Splits a CoL authorship string into [verbatim_author, year_of_publication].
TaxonWorks Protonym validates that verbatim_author contains no digits, so the year must be stored separately in year_of_publication.
CoL provides authorship in two forms:
"Linnaeus, 1758" → author "Linnaeus", year 1758
"(Chatton, 1925) Whittaker & Margulis, 1978" → author "Chatton",
year 1925
(basionym takes precedence when no explicit col_year is given)
If no year can be extracted the author string is returned as-is with year nil.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/vendor/colrapi/name_translation.rb', line 189 def self.(, col_year) return [nil, nil] if .blank? year = col_year.presence&.to_i # When authorship leads with "(Basionym Author, YYYY)" and no explicit # year was supplied, take author and year from the basionym # parenthetical — not the trailing combination authorship. if year.nil? && (m = .match(/\A\(([^)]+),\s*(\d{4})\)/)) = m[1].gsub(/,?\s*\b\d{4}\b/, '').strip = nil if .blank? return [, m[2].to_i] end # Strip any trailing ", YYYY" or " YYYY" from the authorship string. # Also strip years embedded inside parenthetical groups, e.g. # "(Chatton, 1925)". = .gsub(/,?\s*\b\d{4}\b/, '') # ", 1925" and " 1925" .gsub(/\(\s*\)/, '') # empty parens left behind .gsub(/,\s*\z/, '') # trailing comma .strip .then { |a| a.match?(/\A\([^)]*\)\z/) ? a : a.gsub(/\([^)]*\)\s*/, '').strip } # If no explicit year was given, extract the last 4-digit year from the # string (the combination year, not the basionym year in parentheses). if year.nil? if (m = .scan(/\b(\d{4})\b/).last) year = m[0].to_i end end = nil if .blank? [, year] end |
.subgenus_epithet(col_name) ⇒ String?
TaxonWorks Protonym#name for a subgenus: just the epithet.
125 126 127 128 129 130 |
# File 'lib/vendor/colrapi/name_translation.rb', line 125 def self.subgenus_epithet(col_name) return nil if col_name.nil? match = col_name.strip.match(SUBGENUS_NAME) match ? (match[1] || match[2]) : col_name end |
.taxonworks_name_keys(taxon_name) ⇒ Hash
The comparison key of each of a TaxonName's .forms (a subgenus by its epithet).
261 262 263 |
# File 'lib/vendor/colrapi/name_translation.rb', line 261 def self.taxonworks_name_keys(taxon_name) forms(taxon_name).transform_values { |name| name_key(name) } end |
.without_subgenus(name) ⇒ String (private)
Drops every parenthetical, not just a subgenus (an ICZN interpolated species-group name, Art. 6.2, goes too) - fine for a comparison key.
340 341 342 |
# File 'lib/vendor/colrapi/name_translation.rb', line 340 def self.without_subgenus(name) name.gsub(/\s*\([^)]*\)/, '').squeeze(' ').strip end |