Jump to content

Module:Utilities

From WhiskerWiki

This Lua Module of utility functions is a streamlined collection of all personal utility functions used across various instances of MediaWiki managed by Rick Overson. It consolidated functions that were previously distributed across `Module:Custom_functions`, `Module:Custom_functions_new` and `Module:Table_functions`


--[[
This Lua Module of utility functions is a streamlined collection of all personal utility functions used across various instances of MediaWiki managed by Rick Overson.
It consolidated functions that were previously distributed across `Module:Custom_functions`, `Module:Custom_functions_new` and `Module:Table_functions`
--]]


-- ================================================================================
-- Extensions
-- ================================================================================
local cargo = mw.ext.cargo -- for cargo queries if needed





-- ================================================================================
-- Main table
-- ================================================================================
local utils = {}


function utils.get_parent_page_name(frame)
    local page_name = mw.title.getCurrentTitle().text
    local _, _, parent = string.find(page_name, "[^%-]+%-(.+)")
    return parent or page_name
end



-- ========================================
-- Deprecated
-- ========================================
--[[ this parses text that is a single text string separated by commas into an array (with some formatting)
This function may be overly complex, I should considering deprecating it
--]]
function utils.parse_arguments(text, format, link_text)
    if not text or text == "" then return "" end

    local function is_wiki_link(item)
        return item:match("^%[%[.*%]%]$")
    end

    local function is_external_link(item)
        return item:match("^%b[]") or item:match("^https?://")
    end

    local results = {}
    for item in text:gmatch("[^,]+") do
        item = mw.text.trim(item)

        if format == "page" and not is_wiki_link(item) then
            item = '[[' .. item .. ']]'

        elseif format == "ext_link" then
            if item:match("^https?://") then
                -- Raw URL — apply label
                item = "[" .. item .. " " .. (link_text or "Link") .. "]"
            elseif not item:match("^%[https?://.- .-%]$") then
                -- Not a valid full external link, apply formatting anyway
                item = "[" .. item .. " " .. (link_text or "Link") .. "]"
            end
        end

        table.insert(results, item)
    end

    return table.concat(results, ", ")
end





-- ========================================
-- Manipulating strings
-- ========================================
-- Utility function to escape and sanitize a string for safe use in SQL queries
function utils.escape_string_for_sql(input_string)
    if not input_string then return "" end
    local sanitized = mw.text.trim(input_string)
    sanitized = sanitized:gsub("[#%%%-%-]", "")  -- Remove common SQL metacharacters
    sanitized = sanitized:gsub("'", "''")        -- Escape single quotes
    return sanitized
end



--[[
    Escapes and quotes a Lua string for safe inclusion in a SQL WHERE clause.
    
    This function is intended for use with the MediaWiki Cargo extension,
    which requires SQL-style string escaping. It replaces single quotes with
    double single quotes (i.e., `'` → `''`) and wraps the entire string in
    single quotes.

    This is essential to prevent SQL syntax errors or injection issues when
    dealing with user-provided input (e.g., page titles or template arguments).

    Example:
        Input:  O'Brien
        Output: 'O''Brien'

    Returns:
        A single-quoted and escaped string, safe for SQL use.
--]]
function utils.quote_sql_value(value)
    value = mw.text.trim(value)
    return "'" .. (value:gsub("'", "''")) .. "'"
end





-- Function to remove File prefix if it already exists
function utils.remove_file_prefix(file_name)
    local prefix = "File:"
    if string.sub(file_name, 1, string.len(prefix)) == prefix then
        return string.sub(file_name, string.len(prefix) + 1)
    else
        return file_name
    end
end


-- Function to process a file name and add "File:" prefix if needed
function utils.add_file_prefix_gentle(file_name)
    if not file_name or file_name == "" then
        return ""
    end

    file_name = mw.text.trim(file_name)

    -- Check if it's already a full link like [[File:filename|...]]
    local inner_file = mw.ustring.match(file_name, "^%[%[File:([^|%]]+)")
    if inner_file then
        return "File:" .. mw.text.trim(inner_file)
    end

    -- Check if it already starts with File: (case-insensitive)
    if not mw.ustring.match(file_name, "^[Ff]ile:") then
        file_name = "File:" .. file_name
    end

    return file_name
end


function utils.word_italicizer(text, words_to_italicize)
    -- Iterate over each word in the flat list of words to italicize
    for _, word in ipairs(words_to_italicize) do
        -- Use string.gsub to replace the word with its italicized version in the text
        -- The pattern escapes special characters in word and wraps it in wiki markup for italics
        text = text:gsub("(%f[%a]" .. word:gsub("(%W)", "%%%1") .. "%f[%A])", "''%1''")
    end
    return text
end




-- Takes a list-like table of elements and surrounds its elements with specified characters
function utils.element_sandwicher(array, start_char, end_char)
    local result = {}
    local length = #array

    for i = 1, length do
        if array[i] then
            result[i] = start_char .. array[i] .. end_char
        else
            result[i] = start_char .. "No value" .. end_char  -- or some default string
        end
    end
    return result
end





--[[ This function takes an array and italicizes the strings contained therein unless the string contains
parentheses in which case it italicizes only what's inside the parentheses. It was made to format species page titles]] --
function utils.species_italicizer(array)
    local result = {}

    for _, value in ipairs(array) do
        local start_index, end_index = value:find("(%b())")
        local italicized_value

        if start_index then
            local text_inside_parentheses = value:sub(start_index + 1, end_index - 1)
            local italicized_text_inside_parentheses = "<i>" .. text_inside_parentheses .. "</i>"
            italicized_value = value:sub(1, start_index - 1) ..
            "(" .. italicized_text_inside_parentheses .. ")" .. value:sub(end_index + 1)
        else
            italicized_value = "<i>" .. value .. "</i>"
        end

        table.insert(result, italicized_value)
    end

    return result
end



-- Takes an array and only italicizes things inside parentheses (maybe get rid of this function for the more useful species_italicizer above)
function utils.parentheses_italicizer(array)
    local result = {}

    for _, value in ipairs(array) do
        local start_index, end_index = value:find("(%b())")
        local italicized_value

        if start_index then
            local text_inside_parentheses = value:sub(start_index + 1, end_index - 1)
            local italicized_text_inside_parentheses = "<i>" .. text_inside_parentheses .. "</i>"
            italicized_value = value:sub(1, start_index - 1) ..
            "(" .. italicized_text_inside_parentheses .. ")" .. value:sub(end_index + 1)
        else
            italicized_value = value
        end

        table.insert(result, italicized_value)
    end

    return result
end



--[[
Function to take a string delimited by a punctuation mark and format it
to ensure there is a space after each punctuation mark.

Usage:
The function format_delimited_string takes two arguments:
1. input_string (string): The input string to be formatted.
2. delimiter (string, optional): The punctuation mark used as the delimiter. Default is comma (",").

Example:
local input_string = "apple,banana,carrot,date,egg"
local result = format_delimited_string(input_string)
print(result)
-- Output: "apple, banana, carrot, date, egg"

local input_string_with_semicolon = "apple;banana;carrot;date;egg"
local delimiter = ";"
local result_with_semicolon = format_delimited_string(input_string_with_semicolon, delimiter)
print(result_with_semicolon)
-- Output: "apple; banana; carrot; date; egg"
]]
function utils.format_delimited_string(input_string, delimiter)
    -- Set default delimiter to comma if not provided
    delimiter = delimiter or ","

    -- Replace occurrences of the delimiter without space with delimiter followed by a space
    local formatted_string = input_string:gsub(delimiter .. "%s*", delimiter .. " ")
    return formatted_string
end



-- this function takes a wiki page and returns the scientific name within it
function utils.title_to_sci(str)
    local start_idx, end_idx = string.find(str, "%b()")

    if start_idx and end_idx then
        return string.sub(str, start_idx + 1, end_idx - 1)
    else
        return str
    end
end



-- This function counts the elements in a csv sting
function utils.count_comma_elements(input_string)
    if not input_string or input_string == "" then
        return 0
    end

    local count = 0
    for _ in string.gmatch(input_string, "[^,]+") do
        count = count + 1
    end

    -- If there were no commas, it means there's only one element.
    if count == 0 then
        return 1
    end

    return count
end




-- this function extracts text within parentheses
-- defined locally so it can be used here—but also exported
local function extract_parenthetical(input_string)
    return input_string:match("%((.-)%)") or input_string
end
utils.extract_parenthetical = extract_parenthetical








-- ========================================
-- Manipulating generic tables
-- ========================================




--[[Description:

The get_downstream_items function takes a list-like table (list) and a focal item (focal_item). 
It returns a new table that contains only the elements that appear after the focal item in the original list. 
If the focal item is not found, the function returns an empty table.

Parameters:

list (table): A list-like table containing elements. Each element is assumed to be a string or comparable value.
focal_item (string): The item from which we want to start collecting downstream elements.
Returns:

A table containing the elements from the list that come after the specified focal item. If the focal item is not found, an empty table is returned.

--]]
function utils.get_downstream_items(list, focal_item)
    -- Validate inputs
    if not list or type(list) ~= "table" or #list == 0 then
        error("Error: The rank list is empty or invalid.")
    end
    if not focal_item or type(focal_item) ~= "string" then
        error("Error: The focal rank is missing or invalid.")
    end

    local downstream_items = {}
    local start_index = nil

    -- Find the index of the focal item
    for i, item in ipairs(list) do
        if item == focal_item then
            start_index = i
            break
        end
    end

    -- If focal item is not found, provide a verbose error
    if not start_index then
        error("Error: The focal rank '" .. focal_item .. "' is not found in the rank list. Rank list: {" ..
            table.concat(list, ", ") .. "}")
    end

    -- Get all items downstream of the focal item
    for i = start_index + 1, #list do
        table.insert(downstream_items, list[i])
    end

    return downstream_items
end








-- Helper function to check if a table contains a specific value
function utils.table_has_value(tbl, value)
    for _, v in ipairs(tbl) do
        if v == value then
            return true
        end
    end
    return false
end




-- Function that takes a nested table and returns a single, flat table.
--[[
       Description:
           This function takes a nested table as input and returns a single, flat table. Functionally it
           is used for taking a cargo results object which is an indexed array and making it one-stage
           simpler, by removing the indexed "wrappers" around the data. It iterates through each inner table
           in the nested structure, extracting key-value pairs and adding them to the flat table.
           In case of a key conflict (i.e., the same key appearing in multiple inner tables), the function
           overwrites the existing value in the flat table with the new one, and prints a warning message indicating the conflict.
       Parameters:
           nested_table - A table containing multiple inner tables whose key-value pairs need to be flattened.
       Returns:
           flat_table - A table containing all key-value pairs from the nested tables, flattened into a single table.
   ]] --
-- function to unpack a cargo results object into a simple key/value table with no indexed wrapper around it
function utils.unpack_table(nested_table)
    local flat_table = {}

    for _, inner_table in pairs(nested_table) do
        if type(inner_table) == "table" then
            for key, value in pairs(inner_table) do
                if flat_table[key] then
                    print("Warning: Key conflict for '" .. key .. "'. Existing value will be overwritten.")
                end
                flat_table[key] = value
            end
        end
    end

    return flat_table
end

function utils.remove_dupes_from_flat_list(flat_table)
    local seen = {}
    for index, item in ipairs(flat_table) do
        if seen[item] then
            table.remove(flat_table, index)
        else
            seen[item] = true
        end
    end

    -- return keys back to values in simple list
    simple_list = {}

    -- for key, _ in pairs(seen) do
    -- 	table.insert(simple_list, key)
    -- end

    return seen
end

-- this function filters table_a by elements that are in table_b.
function utils.filter_by_intersection(table_a, table_b)
    local filtered_table = {}

    for _, value in ipairs(table_a) do
        for _, b in ipairs(table_b) do
            if value == b then
                table.insert(filtered_table, value)
                break
            end
        end
    end

    return filtered_table
end

--[[ this function finds thing that are in the first array that are not in the second.
I first used it making custom pages that display orhpaned Data namespace pages that can't find
their forward facing partner. Dedupes `a` before comparing, so a repeated value in `a` only
ever produces one entry in the result.
--]]
function utils.in_a_not_b(a, b)
    local result = {}
    local set_b = {}

    -- Create a set of unique elements in table b for efficient lookup
    for _, value in ipairs(b) do
        set_b[value] = true
    end

    -- Create a set of unique elements in table a without duplicates
    local set_a = {}
    for _, value in ipairs(a) do
        if not set_a[value] then
            set_a[value] = true
            -- Check if the element in table a is not present in table b and collect non-matching elements
            if not set_b[value] then
                table.insert(result, value)
            end
        end
    end

    return result
end





-- Helper function to get keys from a table
function keys(t)
    local keyset = {}
    local n = 0
    for k, _ in pairs(t) do
        n = n + 1
        keyset[n] = k
    end
    return keyset
end

-- function that returns subset of rank list based on focal rank
function utils.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" }
    }
    -- Find the index of the specified rank
    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 {} -- Return an empty table if the rank is not found
    end

    local result = {}

    -- Iterate over each chain and construct the output array
    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

--[[
Function to take a dictionary and one focal key/field and outputs a single string that is a formatted
list of text separated by commas.

Usage:
The function utils.comma_separated_list takes three arguments:
1. dict (table): A dictionary containing multiple entries as tables.
2. field_name (string): The key/field to extract unique values from.
3. format (string, optional): The format of the output ("string" or "page"). Default is "string".

Example:
local dict = {
    {species = "Homo sapiens"},
    {species = "Pan troglodytes"},
    {species = "Gorilla gorilla"},
    {species = "Homo sapiens"}, -- Duplicate entry
    {species = "Pan paniscus"},
}

local result = utils.comma_separated_list(dict, "species", "page")

--]]







function utils.key_value_table_to_csv(dict, field_name, format)
    -- Set default value for format if not provided
    format = format or "string"

    -- Table to hold unique values of the specified field
    local uniques_of_rank = {}

    -- Iterate through the dictionary to populate uniques_of_rank
    for i = 1, #dict do
        local entry = dict[i]
        -- Check if entry exists and has the specified field
        if entry and entry[field_name] then
            local focal_rank = entry[field_name]
            -- Mark the value as true in the uniques_of_rank table
            uniques_of_rank[focal_rank] = true
        else
            -- Log an error if the entry is invalid
            mw.log("Invalid entry at index " .. i .. " in dict")
        end
    end

    -- Table to hold sorted unique values
    local sorted_rank = {}
    -- Populate sorted_rank with keys from uniques_of_rank
    for focal_rank, _ in pairs(uniques_of_rank) do
        table.insert(sorted_rank, focal_rank)
    end
    -- Sort the unique values
    table.sort(sorted_rank)

    -- Table to hold the final list items
    local list = {}
    -- Iterate through sorted unique values to format them
    for i = 1, #sorted_rank do
        -- Remove trailing asterisks from the item
        local item = sorted_rank[i]:gsub("%**$", "")

        -- Format the item based on the provided format and field name
        if format == "page" then
            if string.lower(field_name) == "species" or string.lower(field_name) == "genus" then
                -- Format for species or genus with italicized links
                table.insert(list, "''[[" .. item .. "]]''")
            else
                -- Format for other fields with regular links
                table.insert(list, "[[" .. item .. "]]")
            end
        else
            -- Default format as plain string
            table.insert(list, item)
        end
    end

    -- Return the comma-separated list or nil if the list is empty
    if #list > 0 then
        return table.concat(list, ", ")
    else
        return nil
    end
end









-- ========================================
-- Table and string conversions
-- ========================================

--This function takes a comma-delimited string and converts it into a table of trimmed values.
--[[
    This function takes a comma-delimited string and converts it into a table
    of trimmed values. Each value in the input string is separated by a comma,
    and leading and trailing whitespace from each value is removed.

    Parameters:
    csv_string (string|nil): A comma-delimited string containing values to be parsed.
                             If nil, an empty table is returned.

    Returns:
    table: A table containing the trimmed values from the input string.
           If the input is nil, an empty table is returned.

    Example usage:
    local result = utils.parse_csv_to_table(" value1 , value2 ,value3 ")
    -- result: {"value1", "value2", "value3"}

    local empty_result = utils.parse_csv_to_table(nil)
    -- empty_result: {}
--]]
function utils.parse_csv_to_table(csv_string)
    -- Return an empty table if the input is nil
    if csv_string == nil then
        return {}
    end

    -- Check if the argument is a string
    if type(csv_string) ~= "string" then
        error("Invalid argument: csv_string must be a string")
    end

    local t = {}
    for field in csv_string:gmatch("[^,]+") do
        table.insert(t, mw.text.trim(field))
    end
    return t
end




--[[ this parses text that is a single text string separated by commas into an array (with some formatting)
This function may be overly complex, I should considering deprecating it
--]]
function utils.parse_string_to_table(text, format, link_text)
    if text then
        local text_table = {} -- Table to store parsed text elements
        -- Iterate over the delimited string and extract each text element
        for item in text:gmatch("[^,]+") do
            if format == "page" then
                item = '[[' .. item .. ']]' -- Surround item with double brackets
            elseif format == "ext_link" then
                item = "[" .. item .. " " .. link_text .. "]"
            else
                item = item:gsub(",", ", ") -- Add a space after each comma
            end
            table.insert(text_table, item)
        end
        -- Iterate over the arguments table and concatenate them into a string
        local full_text = table.concat(text_table, ", ")
        return full_text
    else
        return full_text
    end
end




function utils.list_to_csv_string(list)
    -- Ensure the input is a table and has elements
    if type(list) ~= "table" or #list == 0 then
        return nil
    end
    -- Concatenate the table elements into a comma-separated string
    return table.concat(list, ", ")
end















-- ========================================
-- Interfacing with taxonomic dictionaries
-- ========================================
-- Helper function to use with the above new dictionary
function utils.check_for_custom_ranks(genealogy, rank_dictionary)
    for _, taxon in ipairs(genealogy) do
        -- Skip nil or blank entries in the genealogy
        if taxon and taxon ~= "" then
            if rank_dictionary[taxon] then
                return rank_dictionary[taxon]
            end
        end
    end
    -- Fallback to 'all_ranks' if no match is found
    return rank_dictionary["all_ranks"]
end













-- ========================================
-- Querying Cargo and manipulating Cargo tables
-- ========================================
-- Function to query a Cargo table and return a single matching row when the field that the search_string is in is not known.
--[[
This function is useful for getting taxonomic information for focal taxon (e.g. "Pogonomyrmex") at the beginning of a pipeline when the
Linneaen rank of that taxon is not known, and thus the field to query is not known (since that information is stored in the Cargo structure).
The function is set up to be as efficient as possible by only querying Cargo one time with a compound OR statement that searchers through each field
in the all_ranks_list. It stops when it finds a match and returns the row (basically a one row Cargo table) where the match occurs. This one-row Cargo
table is then useful for downstream functions that can use it to return the focal rank, the child ranks, upstream values of a focal taxon, etc.
--]]
-- Function to query a Cargo table and return a single matching row in the same table structure
function utils.get_cargo_row(cargo_table_name, search_string, all_ranks_list)
    -- Validate inputs
    if type(cargo_table_name) ~= "string" or type(search_string) ~= "string" or type(all_ranks_list) ~= "table" then
        error("Invalid arguments: expected (string, string, table)")
    end

    -- Build the WHERE clause to check all fields in one query
    local where_clauses = {}
    for _, field in ipairs(all_ranks_list) do
        table.insert(where_clauses, field .. " = '" .. search_string .. "'")
    end
    local where_clause = table.concat(where_clauses, " OR ") -- Combine with OR

    -- Query Cargo for any matching row
    local results = cargo.query(cargo_table_name, table.concat(all_ranks_list, ", "), {
        where = where_clause,
        limit = 1  -- Only fetch one row, since we only care about the first match
    })

    -- Return the result wrapped in a table structure to match the input table format
    return results and {results[1]} or {}
end




-- Function to find the Linneaen rank of a focal taxon—DEPRECATED use `get_focal_rank` function instead
--[[This function was deprecated as it queried Cargo directly (rather than recieving a cargo table as an argument)
--]]
function utils.rank_finder(cargo_table_name, value, all_ranks_list)
    -- Build the WHERE clause to check all fields in one query
    local where_clauses = {}
    for _, field in ipairs(all_ranks_list) do
        table.insert(where_clauses, field .. " = '" .. value .. "'")
    end
    local where_clause = table.concat(where_clauses, " OR ") -- Combine with OR

    -- Query Cargo for any matching row
    local results = cargo.query(cargo_table_name, table.concat(all_ranks_list, ", "), {
        where = where_clause,
        limit = 1  -- Only fetch one row, since we only care about the first match
    })

    -- Check which field matched
    if #results > 0 then
        for _, field in ipairs(all_ranks_list) do
            if results[1][field] == value then
                return field -- Return the field where the match was found
            end
        end
    end

    return nil  -- No match found
end



--- Finds the first matching field in a table of rows based on a given value.
--[[
This function iterates through a pre-fetched table (list of rows) and checks for a match
in the specified fields. It returns the name of the first field where the match is found.

@param cargo_table table A table resulting from a cargo.query operations. Each row should be 
    a table containing key-value pairs, where the keys
                          correspond to field names.
@param value string The value to search for in the specified fields.
@param all_ranks_list table A list of field names (strings) to check for the given value.

@return string|nil The name of the first field where the match is found, or `nil` if no match exists.
--]]


function utils.get_focal_rank(cargo_table, value, all_ranks_list)
    -- Iterate through each row in the provided Cargo table
    for _, row in ipairs(cargo_table) do
        -- Check which field matches the provided value
        for _, field in ipairs(all_ranks_list) do
            if row[field] == value then
                return field -- Return the field where the match was found
            end
        end
    end

    return nil  -- No match found
end












-- ========================================
-- Manipulating Cargo objects directly
-- ========================================




--- Normalize Cargo results into a structured, unambiguous Lua table
-- @param cargo_results (table): Raw result array from mw.ext.cargo.query()
-- @param field_formats (table): Field format table, e.g., { authors = "list_of_string", title = "string" }
-- @param field_delimiters (table): Optional. Delimiters per list field, e.g., { authors = ";", genres = ";" }
-- @return table: { results = {...}, field_types = {...} }
--- Normalize Cargo results into a structured, unambiguous Lua table
-- @param cargo_results (table): Raw result array from mw.ext.cargo.query()
-- @param field_formats (table): Field format table, e.g., { authors = "list_of_string", title = "string" }
-- @param field_delimiters (table): Optional. Delimiters per list field, e.g., { authors = ";", genres = ";" }
-- @return table: { results = {...}, field_types = {...} }
function utils.parse_cargo_results(cargo_results, field_schema)
    local parsed_results = {}
    local field_types = {}

    for _, result in ipairs(cargo_results) do
        local parsed_row = {}

        for field, value in pairs(result) do
            local field_info = field_schema[field]
            if field_info then
                local field_type = field_info.type
                local delimiter = field_info.delimiter or ","

                if field_type:match("^list_of_") then
                    if value == "" then
                        parsed_row[field] = {}
                    else
                        local list = mw.text.split(value, delimiter .. "%s*")
                        for i, v in ipairs(list) do
                            list[i] = mw.text.trim(v)
                        end
                        parsed_row[field] = list
                    end
                    field_types[field] = "list"
                else
                    parsed_row[field] = value
                    field_types[field] = "string"
                end
            else
                -- Unknown field—pass through as string
                parsed_row[field] = value
                field_types[field] = "string"
            end
        end

        table.insert(parsed_results, parsed_row)
    end

    return {
        results = parsed_results,
        field_types = field_types
    }
end






function utils.get_genealogy(cargo_table, focal_rank, search_string, all_rank_list)
    -- Validate input
    if not cargo_table or not focal_rank or not search_string or not all_rank_list then
        error("All arguments are required: cargo_table, focal_rank, search_string, all_rank_list")
    end

    -- Find the initial row matching the search_string in the focal_rank field
    local focal_row = nil
    for _, row in ipairs(cargo_table) do
        if row[focal_rank] == search_string then
            focal_row = row
            break
        end
    end

    -- If no row matches, return an empty table
    if not focal_row then
        return {}
    end

    -- Build the genealogy
    local genealogy = {}

    -- Insert the focal taxon value into the genealogy
    table.insert(genealogy, focal_row[focal_rank])

    -- Iterate through the all_rank_list in order and add higher-level taxa
    local focal_rank_index = nil
    for i, rank in ipairs(all_rank_list) do
        if rank == focal_rank then
            focal_rank_index = i
            break
        end
    end

    if not focal_rank_index then
        error("Focal rank not found in all_rank_list")
    end

    -- Add higher-level values from all_rank_list
    for i = focal_rank_index - 1, 1, -1 do
        local rank = all_rank_list[i]
        if focal_row[rank] then
            table.insert(genealogy, focal_row[rank])
        end
    end

    return genealogy
end







function utils.transform_link_fields(results, display_fields, field_schema, options)
    options = options or {}

    -- Default to "View" if not set
    local link_text = options.link_text or "View"

    -- Build a whitelist if link_text_fields are defined
    local link_text_fields = {}
    if type(options.link_text_fields) == "table" then
        for _, fname in ipairs(options.link_text_fields) do
            link_text_fields[fname] = true
        end
    end

    local transformed = mw.clone(results)

    for _, row in ipairs(transformed) do
        for _, field in ipairs(display_fields) do
            local field_info = field_schema[field]
            if field_info then
                local field_type = field_info.type
                local is_url = field_type == "url"
                local is_list_of_url = field_type == "list_of_url"
                local is_target_field = not options.link_text_fields or link_text_fields[field]

                if is_target_field then
                    local value = row[field]
                    if is_url and type(value) == "string" and value ~= "" then
                        row[field] = string.format("[%s %s]", value, link_text)
                    elseif is_list_of_url and type(value) == "table" then
                        local formatted = {}
                        for _, url in ipairs(value) do
                            table.insert(formatted, string.format("[%s %s]", url, link_text))
                        end
                        row[field] = formatted
                    end
                end
            end
        end
    end

    return transformed
end








function fetch_cargo_column(table_name, field_name)
    -- Define the Cargo query parameters
    local tables = table_name
    local fields = field_name
    local cargo_args = {
        limit = 5000 -- Adjust based on your expected number of rows
    }

    -- Execute the Cargo query
    local cargo_results = mw.ext.cargo.query(tables, fields, cargo_args)

    -- Prepare an array to hold the field values
    local values_array = {}

    -- Extract the specified field from each row and insert into the values_array as simple strings
    for _, row in ipairs(cargo_results) do
        table.insert(values_array, row[field_name])
    end

    return values_array
end




-- local function to find the rank of a geography page name
function utils.geo_field_finder(title)
    local tables = "Geography"
    local fields = "Country, Intermediate_region, Subregion, Region"

    local cargo_args = {
        where = "Country = '" .. title .. "' OR Intermediate_region = '" .. title ..
            "' OR Subregion = '" .. title .. "'  OR Region = '" .. title .. "'"
    }

    local result = cargo.query(tables, fields, cargo_args)

    local matching_field
    for i, record in ipairs(result) do
        if record.Country == title then
            matching_field = "Country"
            break
        elseif record.Intermediate_region == title then
            matching_field = "Intermediate_region"
            break
        elseif record.Subregion == title then
            matching_field = "Subregion"
            break
        elseif record.Region == title then
            matching_field = "Region"
            break
        end
    end

    if matching_field then
        return matching_field
    else
        return "Administrative_unit"
    end
end

--[[ function that trims rows from a cargo results object based on a field and value. All rows in that field
containing the substring specified are removed from the Cargo results object. This is not as useful as just creating an SQL
query with a NOT statement, so maybe I don't need this in the future--]]
function utils.cargo_row_remover(cargo_results, field, value)
    local modified_results = {}
    for _, row in ipairs(cargo_results) do
        -- Check if the field value contains the specified value
        if not row[field] or not string.find(row[field], value) then
            table.insert(modified_results, row)
        end
    end
    return modified_results
end


--[[
    Function: extract_top_cargo_row
    Purpose:
        This function takes a Cargo query result and a comma-separated list of fields,
        and assigns the values from the first row of the result to a new table.
        If no fields parameter is provided, the function automatically uses all
        available fields from the first row of the Cargo results.

    Parameters:
        cargo_results (table) - The table containing the results of a Cargo query.
                                The first row (cargo_results[1]) is used to extract the data.
        fields (string) - An optional comma-separated string of field names to be extracted
                          from the Cargo result. If omitted or nil, all fields from the
                          first row are used.

    Returns:
        args (table) - A table containing the extracted values from the first row of
                             the Cargo results, where each key corresponds to a field name
                             and the value is the data from that field.

    Usage:
        - If you want to extract specific fields:
            local args = extract_top_cargo_row(cargo_results, "Field1, Field2, Field3")

        - If you want to extract all fields from the first row of cargo_results:
            local args = extract_top_cargo_row(cargo_results)
--]]
function utils.extract_top_cargo_row(cargo_results, fields)
    -- Initialize a new table to store cargo results
    local cargo_args = {}

    -- If fields parameter is not provided, use all fields from the first row of cargo_results
    if not fields and cargo_results[1] then
        fields = ""
        for key in pairs(cargo_results[1]) do
            fields = fields .. key .. ", "
        end
        -- Remove the trailing comma and space
        fields = fields:sub(1, -3)
    end

    -- Check if there is at least one result
    if cargo_results[1] then
        -- Loop through the fields and assign each value to cargo_args
        for field in fields:gmatch("[^,]+") do
            local trimmed_field = field:match("^%s*(.-)%s*$") -- Trim spaces
            cargo_args[trimmed_field] = cargo_results[1][trimmed_field]
        end
    end

    return cargo_args
end


--[[This function takes a standard nested array from a cargo query as well as an array representing
the desired format types for each field and outputs a formatted array of the same structure. An example
field_formats array would look like this with the following four valid format arguments:
	local field_formats = {
	    Organization = "page",
	    Acronym = "string",
	    Image="file"
	    Link = "url",
	    Parent_organization = "page",
	    Type = "string",
	    Focus = "list_of_string",
	    Focus_keywords = "list_of_string",
	    Species_purview = "list_of_page",
	}
	]] --
	function utils.format_cargo_results(cargo_results, field_formats, field_name_mappings, file_display_format)
        -- set a default for the file dispaly format if none is provided
        file_display_format = file_display_format or "[[%s|frameless|100px]]"
    
        local formatted_results = {}
        field_name_mappings = field_name_mappings or {}    -- Fallback to empty table if no mappings provided
    
        -- Helper function to handle both comma and semicolon separation
        local function split_and_format_list(value, delimiter)
            local string_list = mw.text.split(value, delimiter)
            for i, v in ipairs(string_list) do
                string_list[i] = mw.text.trim(v)
            end
            return table.concat(string_list, ", ")
        end
    
        for _, result in ipairs(cargo_results) do
            local formatted_result = {}
    
            for field, value in pairs(result) do
                if value and value ~= "" then    -- Check if the value exists and is not an empty string
                    local format = field_formats[field]
                    local formatted_value = value
    
                    if format == "list_of_string" then
                        -- Check if semicolon is present; if yes, split by semicolon, otherwise by comma
                        if value:find(";") then
                            formatted_value = split_and_format_list(value, ";")
                        else
                            formatted_value = split_and_format_list(value, ",")
                        end
                    elseif format == "page" then
                        formatted_value = "[[" .. value .. "]]"
                    elseif format == "list_of_page" then
                        -- Check if semicolon is present; if yes, split by semicolon, otherwise by comma
                        local delimiter = value:find(";") and ";" or ","
                        local page_links = {}
                        local values = mw.text.split(value, delimiter)
    
                        local current_value = ""
                        for i, v in ipairs(values) do
                            v = mw.text.trim(v)
                            if current_value ~= "" then
                                current_value = current_value .. delimiter .. v
                            else
                                current_value = v
                            end
                            -- If the current value contains multiple words, treat it as a complete entry
                            if not v:match("^%s*$") then
                                table.insert(page_links, "[[" .. current_value .. "]]")
                                current_value = ""
                            end
                        end
    
                        -- Handle any remaining value that wasn't added
                        if current_value ~= "" then
                            table.insert(page_links, "[[" .. current_value .. "]]")
                        end
    
                        formatted_value = table.concat(page_links, ", ")
                    elseif format == "url" then
                        formatted_value = string.format("[%s View URL]", value)
					
					elseif format == "file" then
					    local safe_file_name = utils.add_file_prefix_gentle(value)
					    local is_pdf = safe_file_name:lower():match("%.pdf$")
					    if is_pdf then
							local pdf_format_string = file_display_format or "[[%s|thumb|100px|link=%s]]"
							formatted_value = string.format(pdf_format_string, safe_file_name, safe_file_name)
					    else
					        -- Use default format for images and other file types
					        formatted_value = string.format(file_display_format, safe_file_name)
					    end
					end
    
                    -- Apply new field name if mapping exists
                    local new_field_name = field_name_mappings[field] or field
                    formatted_result[new_field_name] = formatted_value
                end
            end
    
            table.insert(formatted_results, formatted_result)
        end
    
        return formatted_results
    end
    
    



--[[This function builds the where clause for a Cargo query--]]
function utils.build_where_clause(cargo_focal_field_type, cargo_focal_field, filter_values_list, not_values_list)
    not_values_list = not_values_list or {}

    if cargo_focal_field == nil then
        error("cargo_focal_field cannot be nil")
    end

    if filter_values_list == nil then
        error("filter_values_list cannot be nil")
    end

    local is_list_type = cargo_focal_field_type == "list_of_string"
        or cargo_focal_field_type == "list_of_page"
        or cargo_focal_field_type == "list_of_url"
        or cargo_focal_field_type == "list_of_file"

    local where_clauses = {}
    for _, value in ipairs(filter_values_list) do
        if value == nil then
            error("Value in filter_values_list cannot be nil")
        end
        local escaped_value = utils.escape_string_for_sql(value)
        if is_list_type then
            table.insert(where_clauses, string.format("%s HOLDS '%s'", cargo_focal_field, escaped_value))
        else
            table.insert(where_clauses, string.format("%s = '%s'", cargo_focal_field, escaped_value))
        end
    end

    local not_clauses = {}
    for _, value in ipairs(not_values_list) do
        if value == nil then
            error("Value in not_values_list cannot be nil")
        end
        local escaped_value = utils.escape_string_for_sql(value)
        if is_list_type then
            table.insert(not_clauses, string.format("%s HOLDS '%s'", cargo_focal_field, escaped_value))
        else
            table.insert(not_clauses, string.format("%s != '%s'", cargo_focal_field, escaped_value))
        end
    end

    local where_clause = table.concat(where_clauses, " OR ")
    if #not_clauses > 0 then
        local not_clause = table.concat(not_clauses, " AND NOT ")
        where_clause = string.format("(%s) AND NOT (%s)", where_clause, not_clause)
    end

    return where_clause
end
    







--This function transforms an indexed array of tables (cargo_results) into a map (associative array).
--[[
           This function transforms an indexed array of tables (cargo_results) into a map (associative array)
           where each key is the value of a specified field (cargo_focal_field) from each table in the array,
           and the value is the corresponding table. This allows for quick lookup of an entry by the value of
           its cargo_focal_field.

    
           Parameters:
           - cargo_results: A table (array) of tables, where each inner table represents a data entry.
           - cargo_focal_field: A string representing the key in each table whose value will be used as the key
             in the resulting map.


    
           Returns:
           - A table (map) where keys are values of the specified field from each entry in the array, and values
             are the corresponding tables.
    
           Example usage:
           local cargo_results = {
               { id = 1, name = "Alice", age = 30 },
               { id = 2, name = "Bob", age = 25 },
               { id = 3, name = "Charlie", age = 35 }
           }
           local cargo_focal_field = "id"
    
           local results_map = create_results_map(cargo_results, cargo_focal_field)
           -- results_map[1] will be { id = 1, name = "Alice", age = 30 }
           -- results_map[2] will be { id = 2, name = "Bob", age = 25 }
           -- results_map[3] will be { id = 3, name = "Charlie", age = 35 }
--]]
    function utils.get_results_map(cargo_results, cargo_focal_field)
        -- Check if cargo_results is a table
        if type(cargo_results) ~= "table" then
            error("Expected cargo_results to be a table, got " .. type(cargo_results))
        end
    
        -- Check if cargo_focal_field is a string
        if type(cargo_focal_field) ~= "string" then
            error("Expected cargo_focal_field to be a string, got " .. type(cargo_focal_field))
        end
    
        local results_map = {}
        for _, entry in ipairs(cargo_results) do
            -- Check if each entry is a table
            if type(entry) ~= "table" then
                error("Expected each entry in cargo_results to be a table, got " .. type(entry))
            end
    
            -- Check if the cargo_focal_field exists in the entry
            if entry[cargo_focal_field] == nil then
                error("Expected field '" .. cargo_focal_field .. "' to exist in each entry")
            end
    
            results_map[entry[cargo_focal_field]] = entry
        end
        return results_map
    end






    
--[[
Generates a formatted and optionally collapsible wiki table from Cargo query results.

This function supports two operational modes:
1. Legacy Compatibility Mode (default):
   - Designed to preserve backward compatibility with older templates and modules.
   - Accepts a flat array of preformatted data rows and a simple list of display fields.
   - Minimal formatting logic; outputs raw field values as-is.
   2. Enhanced Mode:
   - Accepts a structured, normalized result table with rich field schema and display mappings.
   - Dynamically formats fields based on their declared Cargo types:
     • "page" and "list_of_page" fields are automatically wrapped in MediaWiki links (\[\[Page\]\]).
     • "file" and "list_of_file" fields are formatted as image thumbnails using a customizable display format.
     • String and list fields are cleanly joined and displayed.
   - Supports optional column display name mapping to adjust header labels.
   - Supports character count cutoffs to truncate long content.

Optional Features:
- Collapsible tables via `mw-collapsible` classes.
- Optional row and column truncation via `row_cutoff` and `character_cutoff`.
- Customizable display format for file/image fields.

This function is soft-migration ready. It defaults to compatibility mode to avoid breaking existing pages
and can be incrementally transitioned to Enhanced Mode as templates and modules are updated.

Parameters:
@param rows (table): Array of Cargo query result rows.
@param header (string): Optional table header (caption).
@param display_fields (table): List of field names to display as columns.
@param collapse (boolean): Whether to apply collapsible table behavior.
@param character_cutoff (number): Optional maximum character length per cell.
@param field_schema (table): Schema describing field types and delimiters.
@param column_label_map (table): Optional mapping of field names to display names.
@param compat_mode (boolean): Whether to operate in legacy compatibility mode (default: true).
@param file_display_format (string): Optional file format string for image fields.

@return string: Formatted wiki table HTML.
--]]
function utils.generate_wiki_table(rows, display_fields, collapse, character_cutoff, field_schema, column_label_map, compat_mode, file_display_format)
	
	-- Detect legacy argument order and remap
	if type(display_fields) == "string" and type(collapse) == "table" then
	    -- Old order: (rows, header, display_fields, collapse, character_cutoff)
	    -- Remap: rows = rows, display_fields = header (string), collapse = display_fields (table), etc.
	    local header = display_fields
	    display_fields = collapse
	    collapse = character_cutoff
	    character_cutoff = field_schema
	    field_schema = nil
	    column_label_map = nil
	    compat_mode = true
	    file_display_format = nil
	end
	
    -- ========================================
    -- Configuration: File display format
    -- ========================================
    file_display_format = file_display_format or "[[File:%s|frameless|100px]]"

    -- ========================================
    -- Determine compatibility mode
    -- ========================================
    if compat_mode == nil then
        compat_mode = true -- Default: legacy mode
    end

    -- ========================================
    -- Legacy Mode
    -- ========================================
    if compat_mode or type(field_schema) ~= "table" then
        local html = mw.html.create('table')
        html:addClass('wikitable')
        html:addClass('cargo-table')
        html:addClass('sortable')

        local thead = html:tag('tr')
        for _, field in ipairs(display_fields) do
            -- Use friendly labels if provided
	        local label = column_label_map and column_label_map[field] or field
	        thead:tag('th'):wikitext(label)
        end

        for _, row in ipairs(rows) do
            local tr = html:tag('tr')
            for _, field in ipairs(display_fields) do
                tr:tag('td'):wikitext(row[field] or "")
            end
        end

        if collapse then
            html:addClass('mw-collapsible')
            html:addClass('mw-collapsed')
        end

        return tostring(html)
    end

    -- ========================================
    -- Enhanced Mode
    -- ========================================
    local html = mw.html.create('table')
    html:addClass('wikitable')
    html:addClass('cargo-table')
    html:addClass('sortable')

    if header and header ~= "" then
        html:tag('caption'):wikitext(header)
    end

    -- Build header row
    local thead = html:tag('tr')
    for _, field in ipairs(display_fields) do
        local display_name = (column_label_map and column_label_map[field]) or field
        thead:tag('th'):wikitext(display_name)
    end

    -- Build data rows
    for _, row in ipairs(rows) do
        local tr = html:tag('tr')
        for _, field in ipairs(display_fields) do
            local value = row[field]
            local field_info = field_schema[field]
            local field_type = field_info and field_info.type or "string"
            local content = ""

            if type(value) == "table" then
                -- List field
                local formatted_items = {}
                for _, item in ipairs(value) do
                    if field_type == "list_of_page" then
                        table.insert(formatted_items, "[[" .. item .. "]]")
                    elseif field_type == "list_of_file" then
                        if item and item ~= "" then
                            table.insert(formatted_items, string.format(file_display_format, item))
                        end
                    else
                        table.insert(formatted_items, item)
                    end
                end
                content = table.concat(formatted_items, ", ")
            elseif type(value) == "string" then
                -- Single value field
                if field_type == "page" then
                    content = "[[" .. value .. "]]"
                elseif field_type == "file" then
                    if value and value ~= "" then
                        content = string.format(file_display_format, value)
                    else
                        content = "" -- No image if empty
                    end
                else
                    content = value
                end
            end

            -- ========================================
            -- Apply character cutoff if applicable
            -- ========================================
            if character_cutoff
                and field_type ~= "file"
                and field_type ~= "list_of_file"
                and mw.ustring.len(content) > character_cutoff then
                content = mw.ustring.sub(content, 1, character_cutoff) .. "…"
            end

            tr:tag('td'):wikitext(content)
        end
    end

    if collapse then
        html:addClass('mw-collapsible')
        html:addClass('mw-collapsed')
    end

    return tostring(html)
end
  






function utils.generate_wiki_table_enhanced(
    rows, display_fields, collapse, character_cutoff,
    field_schema, column_label_map, compat_mode,
    file_display_format, row_cutoff, options -- still passed but unused here
)
    -- ========================================
    -- Configuration
    -- ========================================
    file_display_format = file_display_format or "[[File:%s|frameless|100px]]"
    compat_mode = (compat_mode == nil) and true or compat_mode
    local row_cutoff_value = row_cutoff or 5
    options = options or {}

    local classes = { "wikitable", "cargo-table", "sortable" }
    if collapse then
        table.insert(classes, "collapsible-cargo-table")
        table.insert(classes, "row-cutoff-" .. row_cutoff_value)
    end

    local table_class = table.concat(classes, " ")

    -- ========================================
    -- Assemble data rows
    -- ========================================
    local lines = {}
    table.insert(lines, '{| class="' .. table_class .. '"')

    -- Header row
    table.insert(lines, '|-')
    for _, field in ipairs(display_fields) do
        local display_name = (column_label_map and column_label_map[field]) or field
        table.insert(lines, '! ' .. display_name)
    end

    for _, row in ipairs(rows) do
        table.insert(lines, '|-')
        for _, field in ipairs(display_fields) do
            local value = row[field]
            local field_info = field_schema and field_schema[field]
            local field_type = field_info and field_info.type or "string"
            local content = ""

            if type(value) == "table" then
                local formatted_items = {}
                for _, item in ipairs(value) do
                    if field_type == "list_of_page" then
                        table.insert(formatted_items, "[[" .. item .. "]]")
                    elseif field_type == "list_of_file" then
                        if item and item ~= "" then
                            table.insert(formatted_items, string.format(file_display_format, item))
                        end
                    else
                        table.insert(formatted_items, item)
                    end
                end
                content = table.concat(formatted_items, ", ")
            elseif type(value) == "string" then
                if field_type == "page" then
                    content = "[[" .. value .. "]]"
                elseif field_type == "file" then
                    if value and value ~= "" then
                        content = string.format(file_display_format, value)
                    end
                else
                    content = value
                end
            end

            if character_cutoff
                and field_type ~= "file"
                and field_type ~= "list_of_file"
                and mw.ustring.len(content) > character_cutoff then
                content = mw.ustring.sub(content, 1, character_cutoff) .. "…"
            end

            table.insert(lines, '| ' .. (content or ""))
        end
    end

    table.insert(lines, '|}')
    return table.concat(lines, "\n")
end
    
    
    
    
    
    
--[[ This function takes the name of a Cargo template (which presumably but not critically has a companion Cargo table with the same name), and returns a flat, 1D array that
       represents the field types for that Cargo table (as defined by the template). Here is a sample structure:
       {   organization = "page",
           image = "file",
           non_english_name = "string",
           parent_organization = "list of page",
           website = "url"
       }
       ]] --
    function utils.get_cargo_table_schema(cargo_template_name)
    local debug_messages = "Debug: Entered get_cargo_table_schema function.\n"

    if not cargo_template_name or cargo_template_name == "" then
        debug_messages = debug_messages .. "Error: Template name is required.\n"
        return nil, debug_messages
    end

    debug_messages = debug_messages .. "Template name: " .. cargo_template_name .. "\n"

    local template_title = mw.title.new(cargo_template_name, 'Template')
    if not template_title then
        debug_messages = debug_messages .. "Error: Failed to create title object for template.\n"
        return nil, debug_messages
    end

    local template_content = template_title:getContent()
    if not template_content then
        debug_messages = debug_messages .. "Error: Failed to retrieve template content.\n"
        return nil, debug_messages
    end

    -- Parse #cargo_declare block
    local pattern = '{{#cargo_declare.-(.-)}}'
    local schema_string = template_content:match(pattern)
    if not schema_string then
        debug_messages = debug_messages .. "Error: Failed to find cargo_declare in template content.\n"
        return nil, debug_messages
    end

    debug_messages = debug_messages .. "Cargo declare block found.\n"

    -- Extract fields and types
    local schema = {}
    for field_name, field_type_raw in schema_string:gmatch('|([^|=]+)%s*=%s*([^\n|]+)') do
        field_name = mw.text.trim(field_name)
        local field_info = {}

        -- Check for list type with delimiter
		local list_delimiter, base_type = field_type_raw:match('List%s*%(%s*(.-)%s*%)%s*of%s*(%w+)')
		if list_delimiter and base_type then
		    field_info.type = 'list_of_' .. string.lower(base_type)
		    field_info.delimiter = list_delimiter
		elseif field_type_raw:match('List') then
		    -- Fallback: list type but delimiter not found → assume comma
		    local base_type_fallback = field_type_raw:match('List of%s*(%w+)')
		    if base_type_fallback then
		        field_info.type = 'list_of_' .. string.lower(base_type_fallback)
		    else
		        field_info.type = 'list_of_unknown'
		    end
		    field_info.delimiter = ","
		    debug_messages = debug_messages ..
		        "Warning: No delimiter found for field '" .. field_name .. "'. Defaulting to comma.\n"
		else
		    -- Single-value field
		    field_info.type = string.lower(mw.text.trim(field_type_raw))
		    field_info.delimiter = nil
		end

        schema[field_name] = field_info
    end

    return schema, debug_messages
end









-- ========================================
-- Modifying cargo results tables into another format
-- ========================================


-- A function that gets rid of duplicate rose in cargo results based on one or more key fields
function utils.deduplicate_rows(rows, key_fields)
    local unique_rows = {}
    local seen = {}

    for _, row in ipairs(rows) do
        local row_key = ""
        for _, field in ipairs(key_fields) do
            row_key = row_key .. "|" .. (row[field] or "")
        end

        if not seen[row_key] then
            table.insert(unique_rows, row)
            seen[row_key] = true
        end
    end

    return unique_rows
end




-- Converts a table into a comma-delimited string. This is most likely only practical to use with a single row table like this:
--[[
local rank_list = {
    "Order_taxon", "Suborder", "Infraorder", "Superfamily", 
    "Family", "Subfamily", "Tribe", "Genus", 
    "Subgenus", "Clade_I", "Species_group", "Clade_II", "Species"
} 
--]]

function utils.table_to_string(rank_table)
    return table.concat(rank_table, ", ")
end







-- ========================================
-- Nested taxonomic tables
-- ========================================

-- Function to convert a Cargo table into a nested hierarchical structure based on an externally-defined hierarchical relationship of its fields
--[[
Builds a nested hierarchical structure from the provided Cargo query results. This function needs two arguments: 1) a Cargo table that contains pertinent
fields named after Linnaean ranks and 2) a simple array-like table containing the list of Linnaean ranks that represent the subset of fields from the Cargo
table that should be included in the nested hierarchical output. This nested strucure is an intermediate object that is usually used in conjunction with other
other functions like `build_bulleted_taxon_list` to create display lists on various pages.
@param cargo_results A table containing the results of a Cargo query, where each entry represents a taxon.
@param taxon_ranks A table specifying the order of taxonomic ranks to use for building the hierarchy (e.g., taxon_ranks = {"Family", "Subfamily", "Tribe"}).
@return A hierarchical table representing the nested structure of taxonomic ranks, where each level's values are is nested under the previous one.
--]]
function utils.build_nested_hierarchy(cargo_results, taxon_ranks)
    -- This root node is just a container; if desired, rename to "All Taxa" or something else
    local root = {
        name = "Root",
        rank = "Root",
        children = {}
    }

    for _, row in ipairs(cargo_results) do
        local current_node = root

        for i, rank in ipairs(taxon_ranks) do
            local taxon_value = row[rank] or "No information"
            if taxon_value == "" then
                taxon_value = "No information"
            end

            -- Look for an existing child with the same name & rank
            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 found, create a new child node
            if not child_node then
                child_node = {
                    name = taxon_value,
                    rank = rank,
                    children = {}
                }
                table.insert(current_node.children, child_node)
            end

            -- Descend into this child for the next rank
            current_node = child_node
        end
    end

    return root
end



-- Function to traverse a nested structure and apply apply_rank_formatting to each node in the structure
--[[
This function acts as the navigator of the nested taxon structure. It systematically visits every node in the hierarchy to determine where formatting is needed.
It does not modify the data itself but ensures all nodes are processed. Note that since it is a recursive function (i.e., is called upon itelf), it can be confusing
that the argument passed to it is actually an individual node in the nested structure, but since it is called on itself it gets called on every node in the
tree.
Responsibilities:
Traverse each level of the hierarchy recursively.
Check if a node qualifies for formatting (e.g., has a field value).
Delegate specific formatting tasks to the apply_rank_formatting function.
Continue deeper into the hierarchy by processing child nodes in the values field.
--]]
function utils.traverse_and_format(node, format_function)
    -- If a formatting function was provided, apply it
    if format_function then
        format_function(node)
    end

    -- Recursively process each child node
    for _, child in ipairs(node.children or {}) do
        utils.traverse_and_format(child, format_function)
    end
end










-- ========================================
-- Output forward-facing html
-- ========================================


-- Function to build and return a bulleted list from a nested hierarchical Linnaean rank object created by `build_nested_hierarchy` function
---
-- Builds a bulleted HTML list representing a hierarchical taxonomic structure.
--
-- @param title The name of the focal taxon for the hierarchy.
-- @param rank The taxonomic rank of the focal taxon (e.g., Family, Genus).
-- @return A string representing the HTML of the bulleted list, or an error message if the hierarchy could not be built.
function utils.build_bulleted_taxon_list(taxon_hierarchy)
    local function recurse(node)
        local list = mw.html.create('ul')

        for _, child in ipairs(node.children or {}) do
            -- Create the list item
            local li = list:tag('li')

            -- Add rank and name, skipping colon if rank is blank
            if child.rank ~= "" then
                li:wikitext(child.rank .. ": " .. child.name)
            else
                li:wikitext(child.name)
            end

            -- Recursively handle child nodes if present
            if #child.children > 0 then
                li:node(recurse(child))
            end
        end

        return list
    end

    -- Instead of building a <li> for the root itself, 
    -- we only display the children of the root node
    local html_list = recurse(taxon_hierarchy)

    return tostring(html_list)
end











--[[This function creates a gallery of images from an already processed object containing Cargo query results. It's intent is to separate the logic of querying
cargo results which can be nuanced depending on the task at hand, from the creation of the forward-facing html wiki gallery.

@param cargo_results: A table of rows retrieved from a Cargo query, each row containing data for one item.
@param focal_field: The key in each row representing the name or title to be displayed in the gallery.
@param file_field: The key in each row representing the filename of the image to be shown in the gallery.
@param placeholder_image: A default image to use if no valid image is found in the row.
@param name_array: An optional array of specific names to include in the gallery, used to order the images.
@param gallery_mode: The display mode for the gallery (e.g., traditional, slideshow).
@return: Returns a string of wikitext that represents the gallery, or an error message if no images are found.
--]]
function utils.create_cargo_gallery(cargo_results, focal_field, file_field, placeholder_image, name_array, gallery_mode)
    if not cargo_results or type(cargo_results) ~= "table" or #cargo_results == 0 then
        return '<span class="gallery-error">No images found for this query—should be coming soon!</span>'
    end

    gallery_mode = gallery_mode or "traditional"  -- Set default mode if nil

    local file_name_map = {}
    local ordered_names = {}

    for _, row in ipairs(cargo_results) do
        local name = row[focal_field]
        if name then
            local image = row[file_field] or placeholder_image
            image = utils.add_file_prefix_gentle(image) -- Ensure the image has "File:" prefix

            if not file_name_map[name] then
                file_name_map[name] = {}
                if not name_array then
                    table.insert(ordered_names, name)
                end
            end

            table.insert(file_name_map[name], image)
        end
    end

    if next(file_name_map) == nil then
        return '<span class="gallery-error">No images found for the provided names!</span>'
    end

    local names_to_use = (type(name_array) == "table" and #name_array > 0) and name_array or ordered_names

    local gallery_lines = {'<gallery heights=200 mode="' .. gallery_mode .. '">'}  -- Use table for efficient string building

    for _, name in ipairs(names_to_use) do
        for _, image in ipairs(file_name_map[name] or {}) do  -- Safe fallback for missing keys
            table.insert(gallery_lines, image .. '|link=' .. name .. '|<center>[[' .. name .. '|' .. name .. ']]</center>')
        end
    end

    table.insert(gallery_lines, '</gallery>')
    return table.concat(gallery_lines, '\n')  -- Efficient string concatenation
end




--[[This function generates a bulleted list from a set of Cargo query results. It allows for optional sorting  
and the ability to format each list item as a wiki link. The function is designed to separate the logic of  
handling Cargo query results from the actual rendering of an HTML-based unordered list.  

@param args: A table containing the function's arguments.  
    @param args.cargo_results: A table of rows retrieved from a Cargo query, each row containing data for one item.  
    @param args.field_name: The key in each row representing the field to extract values from.  
    @param args.sort_alphabetically: A boolean determining whether to sort the list alphabetically (default: false).  
    @param args.page_links: A boolean determining whether to wrap each item in wiki link formatting (default: false).  
@return: Returns a string of HTML representing the unordered list, with optional sorting and wiki links applied.  
--]]
function utils.display_field_in_bulleted_list(args)
    -- Extract arguments with defaults
    local cargo_results = args.cargo_results
    local field_name = args.field_name
    local sort_alphabetically = args.sort_alphabetically or false
    local page_links = args.page_links or false

    -- Create a table to collect all items
    local items = {}

    for _, result in ipairs(cargo_results) do
        -- Extract the value of the specified field from the current result
        local field_value = result[field_name]

        -- Split the field value by commas to create separate list items
        local item_list = mw.text.split(field_value, ",")
        for _, item in ipairs(item_list) do
            local trimmed_item = mw.text.trim(item)
            if page_links then
                trimmed_item = string.format("[[%s]]", trimmed_item) -- Wrap in wiki links
            end
            table.insert(items, trimmed_item)
        end
    end

    -- Sort the items alphabetically if needed
    if sort_alphabetically then
        table.sort(items, function(a, b)
            -- Remove wiki link formatting for sorting comparison
            local clean_a = mw.ustring.gsub(a, "^%[%[(.-)%]%]$", "%1")
            local clean_b = mw.ustring.gsub(b, "^%[%[(.-)%]%]$", "%1")
            return clean_a < clean_b
        end)
    end

    -- Create a <ul> element using mw.html
    local ul = mw.html.create('ul')

    -- Add each sorted item as a <li> element
    for _, item in ipairs(items) do
        ul:tag('li'):wikitext(item):done()
    end

    -- Return the HTML code of the entire list
    return tostring(ul)
end




--[[
Generates a sorted bulleted list of unique values for a specified field.
Optionally applies a cosmetic display name mapping and wraps 'page' fields in wiki links.
]]--
function utils.bulleted_list(results, field_name, field_schema, column_label_map)
    local uniques = {}

    -- Collect unique values
    for _, row in ipairs(results) do
        local value = row[field_name]
        if type(value) == "table" then
            -- list type field
            for _, v in ipairs(value) do
                uniques[v] = true
            end
        elseif type(value) == "string" then
            uniques[value] = true
        end
    end

    -- Sort
    local sorted = {}
    for v, _ in pairs(uniques) do
        table.insert(sorted, v)
    end
    table.sort(sorted)

    -- Generate list
    local list = {}
    local field_info = field_schema[field_name]
    local field_type = field_info and field_info.type or "string"

    for _, item in ipairs(sorted) do
        local display = item
        if field_type == "page" or field_type == "list_of_page" then
            display = "[[" .. display .. "]]"
        end
        table.insert(list, mw.html.create('li'):wikitext(display))
    end

    if #list > 0 then
        local result_list = mw.html.create('ul')
        for _, li in ipairs(list) do
            result_list:node(li)
        end
        return tostring(result_list) .. "\n" -- Prevent header collision
    else
        return nil
    end
end







-- ========================================
-- Changing the formatting and grammar of Linnaen ranks
-- ========================================


-- Function to convert ranks to a specific format using a lookup table with formatting
function utils.format_ranks(rank_table, rank_lookup, format_type)
    local formatted_ranks = {}

    for i, rank in ipairs(rank_table) do
        -- Check if the rank exists in the lookup table
        if rank_lookup[rank] then
            -- Retrieve the rank with the specified format
            local formatted_rank = rank_lookup[rank][format_type]
            -- If the format type is found, insert it, otherwise insert the original rank
            table.insert(formatted_ranks, formatted_rank or rank)
        else
            -- If the rank is not found in the lookup table, insert it as-is
            table.insert(formatted_ranks, rank)
        end
    end

    return formatted_ranks
end

















-- ================================================================================
-- Consolidated from Module:Custom_functions
-- ================================================================================
-- These had no equivalent above and were ported over verbatim (get_field_names
-- fixed -- it used to build field_names and then `return` bare instead of
-- `return field_names`, so it always returned nil) when Module:Custom_functions
-- was retired in favor of this module. Module:Custom_functions is now just a
-- redirect here.

--- Generates a wiki link back to the state page based on the current page title.
-- Intended for use on pages named in the format "Place, State" (e.g., "Marengo County, Alabama").
-- Extracts the state name from the page title and returns a link in the form:
-- [[State|Return to State]]
--
-- @param frame table The Scribunto frame object (unused in this function).
-- @return string A wikitext-formatted link to the state page, or an empty string if no match is found.
function utils.return_state_link(frame)
    local title_obj = mw.title.getCurrentTitle()
    local title = title_obj.text
    local state = title:match(",%s*(.+)$")

    if not state then
        return ''
    end

    return string.format('[[%s|Return to %s]]', state, state)
end




--[[ This function takes a cargo results array and a flat, 1D table
containing strings representing field names and produces a nested bulleted wiki text list for display
--]]
function utils.bulleted_list_multi(dict, field_names, show_field_names)
    -- Set default value for show_field_names if not provided
    if show_field_names == nil then
        show_field_names = true
    end

    local hierarchy = {}

    -- Build the hierarchy based on the dict and field_names
    for _, entry in ipairs(dict) do
        local current_node = hierarchy

        for _, field_name in ipairs(field_names) do
            -- Skip empty fields to omit them from the list
            if entry and entry[field_name] and entry[field_name] ~= "" then
                local value = entry[field_name]:gsub("%**$", "") -- Remove trailing asterisks
                current_node[value] = current_node[value] or {}
                current_node = current_node[value]
            else
                -- If the field is empty, skip this iteration
                break
            end
        end
    end

    -- Recursive function to render the hierarchy as a nested bulleted list using mw.html
    local function recursive_list_render(node, depth)
        local sorted_keys = {}
        for key, _ in pairs(node) do
            table.insert(sorted_keys, key)
        end
        table.sort(sorted_keys)

        local list = mw.html.create('ul')
        for _, key in ipairs(sorted_keys) do
            local list_item = mw.html.create('li')

            -- Determine if field names should be shown
            local display_text
            if show_field_names then
                local field_name_display = field_names[depth]:gsub("_", " ") -- Replace underscores with spaces
                display_text = field_name_display .. ": " .. key
            else
                display_text = key
            end

            -- Use mw.html:wikitext to add the content as wikitext
            if depth == #field_names and (string.lower(field_names[depth]) == "species" or string.lower(field_names[depth]) == "genus") then
                list_item:wikitext("''" .. display_text .. "''")
            else
                list_item:wikitext(display_text)
            end

            local sub_list = recursive_list_render(node[key], depth + 1)
            if sub_list then
                list_item:node(sub_list)
            end

            list:node(list_item)
        end
        return (#sorted_keys > 0) and list or nil
    end

    return tostring(recursive_list_render(hierarchy, 1))
end




-- Loop through the rows in an input array and remove contiguous duplicates in each field
function utils.dupes_remover(input_array)
    local prev_values = {}
    for i, row in ipairs(input_array) do
        for j, field in ipairs(row) do
            -- Check if the current field value is the same as the previous one for this column
            if i > 1 and field == prev_values[j] then
                -- If so, set the field value to an empty string
                row[j] = ""
            else
                -- If not, update the previous value for this column
                prev_values[j] = field
            end
        end
    end
    return input_array
end




--[[ Loop through the cells in an array before displaying on a forward-facing
page and surrounding brackets to make them wiki page links. Default behavior
will convert all columns or a list of only columns desired can be specified.
header = false will not skip the first row.]]
function utils.bracketer(input_array, columns)
    local result = {}
    local column_indices = {}

    for i, row in ipairs(input_array) do
        local modified_row = {}

        for j, value in ipairs(row) do
            local modified_value = value
            -- Add brackets to the specified column values if columns are provided and the value is not blank or nil
            if column_indices[j] and value ~= "" and value ~= nil then
                modified_value = "[[" .. value .. "]]"
            end
            table.insert(modified_row, modified_value)
        end

        table.insert(result, modified_row)

        -- Process the first row to determine column indices
        if i == 1 then
            -- Identify the column indices to modify based on the provided column names or bracket all columns by default
            if columns then
                for k, column in ipairs(row) do
                    if string.find(columns, column) then
                        column_indices[k] = true
                    end
                end
            else
                for k = 1, #row do
                    column_indices[k] = true
                end
            end
        end
    end

    return result
end




-- Function that extracts the official versions of the site names on the wiki
function utils.get_site_name()
    -- query cargo to get official compendium names
    local tables = "Compendium_info"
    local fields = "Site_number, Site_name, Site_name_long"
    local cargo_results = cargo.query(tables, fields)

    -- initialize the variables
    local site = {}

    -- for loop to extract the results from the cargo.query output
    for _, row in ipairs(cargo_results) do
        local site_num = tonumber(row.Site_number)
        site[site_num] = {
            name = row.Site_name,
            name_long = row.Site_name_long
        }
    end

    return site
end




-- Simple function to capitalize only the first character of a string
function utils.first_char_caps(str)
    return str:gsub("^%l", string.upper)
end




-- Simple function to return the field names of a returned Cargo query
function utils.get_field_names(cargo_query)
	for i, row in ipairs(cargo_query) do
	    local field_names = ""
	    for field_name, _ in pairs(row) do
	        field_names = field_names .. field_name .. ", "
	    end
	    field_names = field_names:sub(1, -3) -- Remove the trailing comma and space
	    return field_names
	end
end




-- ================================================================================
-- For troubleshooting and debugging
-- ================================================================================
function utils.print_table(t, indent)
    indent = indent or ""
    if type(t) ~= "table" then
        return tostring(t)
    end

    local lines = {}
    for k, v in pairs(t) do
        if type(v) == "table" then
            table.insert(lines, indent .. tostring(k) .. " = {")
            table.insert(lines, utils.print_table(v, indent .. "  "))
            table.insert(lines, indent .. "}")
        else
            table.insert(lines, indent .. tostring(k) .. " = " .. tostring(v))
        end
    end
    return table.concat(lines, "\n")
end







return utils
Cookies help us deliver our services. By using our services, you agree to our use of cookies.