Module:Utilities
Appearance
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