Module:Taxonomy utilities
Appearance
Documentation for this module may be created at Module:Taxonomy utilities/doc
--[[
Module:Taxonomy utilities
Part of the Module:Utilities family.
Domain-specific logic for Orthoptera taxonomy and geography.
Covers rank chains, genealogy helpers, geographic field lookup,
and nested hierarchy construction.
All functions are re-exported through Module:Utilities for backward compatibility.
Functions:
get_child_ranks Return child ranks and their formatted variants below a focal rank
format_ranks Convert a rank array to a specific format using a lookup table
check_for_custom_ranks Look up a custom rank set from a genealogy and rank dictionary
geo_field_finder Identify which Geography Cargo field a page title belongs to
build_nested_hierarchy Build a nested hierarchical structure from Cargo results
traverse_and_format Recursively traverse a nested hierarchy and apply a format function
rank_finder Extract a sci-name from a page title and look up its rank in the Species table
--]]
local cargo = mw.ext.cargo
local tax = {}
-- ============================================================
-- Rank chain helpers
-- ============================================================
--[[
Returns child ranks and their formatted variants for all ranks below
the specified focal rank in the standard rank chain.
Returns a table keyed by format type:
result.lowercase -- e.g. {"subfamily", "genus", "species"}
result.plural -- e.g. {"subfamilies", "genera", "species"}
result.cargo_field -- e.g. {"Subfamily", "Genus", "Species"}
result.title -- e.g. {"Subfamily", "Genus", "Species"}
--]]
function tax.get_child_ranks(rank)
local rank_chain = { "family", "subfamily", "genus", "species" }
local variable_lookup = {
lowercase = rank_chain,
plural = { "families", "subfamilies", "genera", "species" },
cargo_field = { "Family", "Subfamily", "Genus", "Species" },
title = { "Family", "Subfamily", "Genus", "Species" },
}
local index = nil
for i = 1, #rank_chain do
if rank_chain[i] == rank then
index = i
break
end
end
if not index then return {} end
local result = {}
for chain, focal_variable in pairs(variable_lookup) do
local chain_result = {}
for i = index + 1, #rank_chain do
table.insert(chain_result, focal_variable[i])
end
result[chain] = chain_result
end
return result
end
--[[
Converts a rank array to a specific display format using a lookup table.
Returns the original rank string for any rank not found in the lookup.
rank_lookup -- table keyed by rank name, each value is a table of formats
format_type -- key into each rank's format table (e.g. "plural", "title")
--]]
function tax.format_ranks(rank_table, rank_lookup, format_type)
local formatted_ranks = {}
for _, rank in ipairs(rank_table) do
if rank_lookup[rank] then
table.insert(formatted_ranks, rank_lookup[rank][format_type] or rank)
else
table.insert(formatted_ranks, rank)
end
end
return formatted_ranks
end
-- ============================================================
-- Custom rank dictionary
-- ============================================================
--[[
Walks a genealogy array and checks each taxon against a rank dictionary.
Returns the first matching entry, or falls back to rank_dictionary["all_ranks"].
Useful for applying custom display rules to specific higher taxa
(e.g. a family with non-standard rank treatment).
--]]
function tax.check_for_custom_ranks(genealogy, rank_dictionary)
for _, taxon in ipairs(genealogy) do
if taxon and taxon ~= "" then
if rank_dictionary[taxon] then
return rank_dictionary[taxon]
end
end
end
return rank_dictionary["all_ranks"]
end
-- ============================================================
-- Geography field lookup
-- ============================================================
--[[
Identifies which field in the Geography Cargo table a given page title
belongs to, by querying each candidate field in priority order.
Returns one of: "Country", "Intermediate_region", "Subregion", "Region"
Returns opts.return_on_missing (default nil) if no match is found.
opts:
strict -- if true, use exact string equality (no normalization)
debug -- if true, log diagnostics via mw.log
return_on_missing -- value to return when title is not found
--]]
function tax.geo_field_finder(title, opts)
opts = opts or {}
if not title or mw.text.trim(tostring(title)) == "" then
return opts.return_on_missing
end
local function norm(s)
if s == nil then return nil end
s = tostring(s)
s = mw.text.trim(s)
s = mw.ustring.gsub(s, "\194\160", " ") -- NBSP -> space
s = mw.ustring.gsub(s, "_", " ") -- underscores -> spaces
s = mw.ustring.gsub(s, "%s+", " ") -- collapse whitespace
s = mw.ustring.lower(s)
return s
end
local function eq(a, b)
if opts.strict then
return tostring(a or "") == tostring(b or "")
else
return norm(a) == norm(b)
end
end
local esc = tostring(title)
if cargo and cargo.escapeString then
esc = cargo.escapeString(esc)
else
esc = esc:gsub("'", "''")
end
local tables = "Geography"
local fields = "Country, Intermediate_region, Subregion, Region"
local rank_fields = { "Country", "Intermediate_region", "Subregion", "Region" }
for _, field in ipairs(rank_fields) do
local cargo_args = {
where = field .. " = '" .. esc .. "'",
limit = 50
}
local result = cargo.query(tables, fields, cargo_args)
if opts.debug then
mw.log(("geo_field_finder: title=[%s] field=%s results=%s"):format(
title, field, tostring(#result)))
if result[1] then mw.logObject(result[1], "geo_field_finder first row") end
end
for _, record in ipairs(result) do
if eq(record[field], title) then
return field
end
end
-- Fallback key check for legacy field naming oddities
for _, record in ipairs(result) do
if field == "Country" and eq(record.Country, title) then return "Country" end
if field == "Intermediate_region" and eq(record.Intermediate_region, title) then return "Intermediate_region" end
if field == "Subregion" and eq(record.Subregion, title) then return "Subregion" end
if field == "Region" and eq(record.Region, title) then return "Region" end
end
end
if opts.debug then
mw.log(("geo_field_finder: NOT FOUND title=[%s]"):format(title))
end
return opts.return_on_missing
end
-- ============================================================
-- Nested hierarchy
-- ============================================================
--[[
Builds a nested hierarchical structure from a Cargo results array,
using taxon_ranks to define the nesting order.
Returns a root node table:
{ name = "Root", rank = "Root", children = { ... } }
Each child node has the same structure, with children nested recursively.
"No information" is substituted for blank or nil rank values.
--]]
function tax.build_nested_hierarchy(cargo_results, taxon_ranks)
local root = {
name = "Root",
rank = "Root",
children = {}
}
for _, row in ipairs(cargo_results) do
local current_node = root
for _, rank in ipairs(taxon_ranks) do
local taxon_value = row[rank] or "No information"
if taxon_value == "" then taxon_value = "No information" end
local child_node = nil
for _, child in ipairs(current_node.children) do
if child.name == taxon_value and child.rank == rank then
child_node = child
break
end
end
if not child_node then
child_node = {
name = taxon_value,
rank = rank,
children = {}
}
table.insert(current_node.children, child_node)
end
current_node = child_node
end
end
return root
end
--[[
Recursively traverses a nested hierarchy produced by build_nested_hierarchy,
applying format_function to every node.
format_function receives a single node table as its argument.
Passing nil for format_function is safe (no-op traverse).
--]]
function tax.traverse_and_format(node, format_function)
if format_function then
format_function(node)
end
for _, child in ipairs(node.children or {}) do
tax.traverse_and_format(child, format_function)
end
end
-- ============================================================
-- Rank finder (sci-name-from-title)
-- ============================================================
local function keys(t)
local keyset = {}
local n = 0
for k, _ in pairs(t) do
n = n + 1
keyset[n] = k
end
return keyset
end
--[[
Ported from Module:Custom functions -- not a duplicate of
Module:Cargo query utilities' rank_finder, despite the shared name; that one
answers "which field in all_ranks_list matches this value" for an arbitrary
Cargo table. This one is specific to the Species table: it extracts a
scientific name from a page title (for wikis whose titles follow the
"Common name (Scientific name)" pattern) and looks up which taxonomic rank
field it matches, returning a rank-metadata bundle.
Returns a table shaped like:
{ lowercase = ..., title = ..., plural = ..., cargo_field = ... }
or, if nothing matched:
{ error = true, message = "No matching record found" }
--]]
function tax.rank_finder(title)
local function process_string(input_string)
return input_string:match("%((.-)%)") or input_string
end
title = process_string(title)
local tables = "Species"
local taxon_fields = { "Family", "Subfamily", "Genus", "Species" }
local taxonomy_fields = {}
for _, field in ipairs(taxon_fields) do
local lowercase = string.lower(field)
if field == "Genus" then
taxonomy_fields[field] = { lowercase = lowercase, title = field, plural = "genera" }
else
taxonomy_fields[field] = { lowercase = lowercase, title = field, plural = lowercase .. (field == "Species" and "" or "s") }
end
end
local where_clauses = {}
for field in pairs(taxonomy_fields) do
table.insert(where_clauses, field .. " = '" .. title .. "'")
end
local cargo_args = { where = table.concat(where_clauses, " OR ") }
local cargo_result = cargo.query(tables, table.concat(keys(taxonomy_fields), ", "), cargo_args)
local output_table = {}
for _, row in ipairs(cargo_result) do
for field, attributes in pairs(taxonomy_fields) do
if row[field] == title then
output_table = attributes
output_table.cargo_field = field
break
end
end
if next(output_table) then break end
end
if not next(output_table) then
output_table.error = true
output_table.message = "No matching record found"
else
output_table.error = false
end
return output_table
end
return tax