Jump to content

Module:Taxonomy utilities

From HopperWiki

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
Cookies help us deliver our services. By using our services, you agree to our use of cookies.