Jump to content

Module:Infobox functions

From HopperWiki

Documentation for this module may be created at Module:Infobox functions/doc

local cargo = mw.ext.cargo -- for cargo queries if needed
local str = require("Module:String utilities")
local cfmt = require("Module:Cargo format utilities")
local json = require("Module:JSON_pipeline")






local ibf = {}







-- =============================================================================
-- NOTE: LIST FIELD RENDERING (TEMPORARY ARCHITECTURE)
-- =============================================================================
-- We are currently using `raw_record` (unformatted Cargo results) for fields
-- that need structured rendering (e.g., bulleted + collapsible lists).
--
-- This is necessary because `Module:Cargo format utilities`'s format_cargo_results_with_schema() flattens
-- list fields (e.g., "~~" delimited values) into comma-separated strings for
-- inline display, which destroys list structure.
--
-- Current pattern:
--   - `record`      = formatted, display-ready values (good for simple rows)
--   - `raw_record`  = original values (required for structured list rendering)
--
-- This render_threshold_list pipeline below is a pragmatic, low-risk workaround to avoid modifying the global
-- formatter.
--
-- FUTURE IMPROVEMENT:
-- The formatting pipeline should be refactored to separate:
--
--   (1) STRUCTURAL FORMATTING
--       - Preserve list fields as Lua tables (not flattened strings)
--       - Example:
--           "a~~b~~c" → { "a", "b", "c" }
--
--   (2) PRESENTATION FORMATTING
--       - Decide how to render:
--           • inline ("a, b, c")
--           • bullet list
--           • collapsible list
--           • table, etc.
--
-- Ideally, `format_cargo_results_with_schema()` should support a mode like:
--   preserve_list_structure = true
--
-- Until then, any field requiring advanced rendering (lists, maps, etc.)
-- should use `raw_record` to avoid losing structure.
-- =============================================================================


-- New functions to allow collapsible bulleted lists inside infoboxes
	-- NEW HELPER FUNCTIONS FOR BULLETED LISTS INSIDE INFOBOX
	
	local function normalize_to_list(value, delim)
		    delim = delim or '~~'
		
		    if not value or value == '' then
		        return {}
		    end
		
		    local out = {}
		
		    if type(value) == 'table' then
		        for _, v in ipairs(value) do
		            if v ~= nil then
		                local s = mw.text.trim(tostring(v))
		                if s ~= '' then
		                    table.insert(out, s)
		                end
		            end
		        end
		        return out
		    end
		
		    for item in mw.text.gsplit(tostring(value), delim, true) do
		        item = mw.text.trim(item)
		        if item ~= '' then
		            table.insert(out, item)
		        end
		    end
		
		    return out
		end
		
		
	local function build_ul(items, extra_class)
			    if not items or #items == 0 then
			        return nil
			    end
			
			    local ul = mw.html.create('ul')
			        :addClass('infobox-list')
			
			    if extra_class and extra_class ~= '' then
			        ul:addClass(extra_class)
			    end
			
			    for _, item in ipairs(items) do
			        ul:tag('li'):wikitext(item)
			    end
			
			    return ul
			end
			
	-- render_threshold_list(value, opts)
	-- Splits a delimited string into items and renders as a bulleted HTML list.
	-- If item count exceeds opts.threshold, splits into visible and collapsible sections.
	-- opts: threshold (default 10), delim (default '~~'), link (wrap items in [[...]]),
	--       collapse (bool), collapse_label, expanded_label.
	-- Returns nil if value is empty, plain <ul> if collapse disabled or under threshold,
	-- otherwise a <div> with visible <ul> and mw-collapsible hidden <ul>.
	local function render_threshold_list(value, opts)
			    opts = opts or {}
			
			    local items = normalize_to_list(value, opts.delim or '~~')
			    
			    -- Link items if requested
				if opts.link then
				    for i, item in ipairs(items) do
				        items[i] = '[[' .. item .. ']]'
				    end
				end
			    
			    local threshold = opts.threshold or 10
			    local collapse_label = opts.collapse_label or 'Show less'
			    local expanded_label = opts.expanded_label or 'Show more'
			    local collapse_enabled = opts.collapse ~= false
			
			    if #items == 0 then
			        return nil
			    end
			
			    if (not collapse_enabled) or (#items <= threshold) then
			        return tostring(build_ul(items))
			    end
			
			    local wrapper = mw.html.create('div')
			        :addClass('infobox-list-wrapper')
			
			    local visible_items = {}
			    local hidden_items = {}
			
			    for i, item in ipairs(items) do
			        if i <= threshold then
			            table.insert(visible_items, item)
			        else
			            table.insert(hidden_items, item)
			        end
			    end
			
			    wrapper:node(build_ul(visible_items, 'infobox-list-visible'))
			
			    local collapsible = wrapper:tag('div')
			        :addClass('mw-collapsible')
			        :addClass('mw-collapsed')
			        :addClass('infobox-collapsible-list')
			        :attr('data-expandtext', expanded_label)
			        :attr('data-collapsetext', collapse_label)
			
			    collapsible:node(build_ul(hidden_items, 'infobox-list-hidden'))
			
			    return tostring(wrapper)
			end
			
			
	ibf.render_threshold_list = render_threshold_list

	-- render_threshold_field(record, field_name, schema, opts)
	-- Wrapper around render_threshold_list that resolves field values and formatting
	-- behavior from a Cargo record and JSON schema before building the list HTML.
	-- Schema-derived defaults: link (from "page"/"list_of_page" type), delim.
	-- Caller opts always take precedence over schema-derived values.
	-- Returns nil if record or field_name is missing.	
	function ibf.render_threshold_field(record, field_name, schema, opts)
	    if not record or not field_name then
	        return nil
	    end
	
	    opts = opts or {}
	    
	    
	    -- Apply defaults (only if caller didn't specify)
	    if opts.threshold == nil then opts.threshold = 5 end
	    if opts.collapse == nil then opts.collapse = true end
	    if opts.collapse_label == nil then opts.collapse_label = 'Show less' end
	    if opts.expanded_label == nil then opts.expanded_label = 'Show more' end
	
	    if schema then
	        local field_def = schema[field_name]
	        if field_def then
	            -- Derive link from schema type
	            if opts.link == nil then
	                local t = field_def.type
	                if t == "page" or t == "list_of_page" then
	                    opts.link = true
	                end
	            end
	            -- Derive delimiter from schema (caller can still override)
	            if opts.delim == nil and field_def.delimiter then
	                opts.delim = field_def.delimiter
	            end
	        end
	    end
	
	    return render_threshold_list(record[field_name], opts)
	end
	
	
	
	function ibf.add_threshold_row(root, label, record, field_name, schema, html_theme_class, opts)
		local value = ibf.render_threshold_field(record, field_name, schema, opts)
    	return ibf.add_list_row(root, label, value, html_theme_class)
	end



-- ibf.query_and_format(cargo_table, fields, cargo_args, schema_page_title)
-- Shared boilerplate for infobox modules that query a single Cargo record and
-- format it against a JSON field-map schema page.
-- Returns raw_record, record, schema on success.
-- Returns nil, nil, nil, error_message if the query is empty or the schema fails to load.
function ibf.query_and_format(cargo_table, fields, cargo_args, schema_page_title)
	local cargo_results = cargo.query(cargo_table, fields, cargo_args)
	if not (cargo_results and #cargo_results > 0) then
		return nil, nil, nil, "⚠️ No results returned."
	end

	local schema = json.load_json_page(schema_page_title)
	if not schema or not next(schema) then
		return nil, nil, nil, "⚠️ Error: Could not load schema page \"" .. tostring(schema_page_title) .. "\""
	end

	local formatted = cfmt.format_cargo_results_with_schema(cargo_results, schema)
	return cargo_results[1], formatted[1], schema
end



-- ibf.resolve_image(image, fallback)
-- Returns image if it's non-nil/non-empty, otherwise fallback.
function ibf.resolve_image(image, fallback)
	if not image or image == "" then
		return fallback
	end
	return image
end










-- Helper function to escape_html
local function escape_html(text)
    if not text or text == "" then return "" end -- Prevent error if text is nil

    text = text:gsub("&(?![%w#]+;)", "&amp;")   -- Escape ampersands unless already part of an entity (e.g. &amp;, &#39;)
    text = text:gsub("'", "&#39;")              -- Escape single quotes
    text = text:gsub('"', "&quot;")             -- Escape double quotes
    text = text:gsub(">", "&gt;")               -- Escape greater-than sign
    text = text:gsub("<", "&lt;")               -- Escape less-than sign
    text = text:gsub("&lt;br ?/?&gt;", "<br/>")  -- Allow <br> or <br/>

    -- Allow known safe formatting tags back in (optional)
    text = text:gsub("&lt;i&gt;", "<i>")        -- Unescape <i> tag
    text = text:gsub("&lt;/i&gt;", "</i>")      -- Unescape </i> tag
    text = text:gsub("&lt;b&gt;", "<b>")        -- Unescape <b> tag
    text = text:gsub("&lt;/b&gt;", "</b>")      -- Unescape </b> tag

    return text                                 -- Return the processed, safe HTML text
end



function ibf.create_infobox(root, title, subtitle, html_theme_class)
    
    -- Escape HTML characters in title
    title = escape_html(title)
    subtitle = escape_html(subtitle)    
    
    
    -- Begin creating the html table infobox template
    root = mw.html.create('table')
        :addClass('infobox')
        :addClass('infobox' .. html_theme_class)

    -- Prepare title and subtitle content
    local content = mw.html.create('')
    content:wikitext(title)
    if subtitle and subtitle ~= '' then
        -- Add the subtitle directly after the title, within the same <th> element
        content
            :newline()
            :tag('div') -- Use a div for subtitle for more control
                :addClass('infobox-subtitle')
                :wikitext(subtitle)
                :allDone()
    end

    -- Add title (and subtitle) to the infobox
    root:tag('tr')
        :tag('th')
            :addClass('infobox-title')
            -- add additional styles based on taxon
            :addClass('infobox-title' .. html_theme_class)
            :attr('colspan', 2)
            :node(content) -- Use the prepared content that includes both title and subtitle
            :done()

    -- Add spacer row after the title/subtitle
    root:tag('tr')
        :tag('td')
            :addClass('spacer-row')
            :attr('colspan', 2)
        :done()

    return root
end





function ibf.add_subtitle(root, text, html_theme_class, colspan)
    if text then
	    root:tag('tr')
	        :tag('th')
	            :addClass('infobox-subtitle')
	            :addClass('infobox-subtitle' .. html_theme_class)
	            :wikitext(text)
	            :attr('colspan', colspan or 2)
	            :css('text-align', 'center')
	        :done()
	        
	    -- Add spacer row after
        root:tag('tr')
            :tag('td')
                :addClass('spacer-row')
                :attr('colspan', colspan or 2)
            :done()
            return root
    	else return root
	end	
end





function ibf.add_header(root, text, html_theme_class, colspan)
    if text then
        -- Add spacer row before header
        root:tag('tr')
            :tag('td')
                :addClass('spacer-row') -- Use a different class if styling differs from the after-header spacer
                :attr('colspan', colspan or 2)
            :done()

        -- Add header row
        root:tag('tr')
            :tag('th')
                :addClass('infobox-header')
                :addClass('infobox-header' .. html_theme_class)
                :wikitext(text)
                :attr('colspan', colspan or 2)
                :css('text-align', 'center')
            :done()

        -- Add spacer row after header (as before)
        root:tag('tr')
            :tag('td')
                :addClass('spacer-row')
                :attr('colspan', colspan or 2)
            :done()

        return root
    else 
        return root
    end
end





function ibf.add_image(root, image, html_theme_class, caption)

    html_theme_class = html_theme_class or ""

    local function strip_file_markup(img)
        local filename = img:match("%[%[File:(.-)|") or img:match("%[%[File:(.-)%]%]")
        return filename and mw.text.trim(filename) or img
    end

    if image == nil or image == "" then
        return root
    end

    caption = (caption ~= nil and caption ~= "") and mw.text.trim(caption) or nil

    local is_url = image:match("^https?://") ~= nil
    local is_preformatted_file = image:match("^%[%[File:.*%]%]") ~= nil

    -- Only add "File:" prefix if not preformatted or external
    if not is_url and not is_preformatted_file then
        image = str.add_file_prefix_gentle(image)
    end

    local td = root:tag('tr'):tag('td')
    td:addClass('infobox-image')
      :addClass('infobox-image' .. html_theme_class)
      :attr('colspan', 2)

    if is_url then
        -- External image/URL: leave as-is (no reliable hover overlay unless you render <img>)
        td:wikitext(image)

    else
        -- Normalize file name for output
        if is_preformatted_file then
            image = strip_file_markup(image)
        end
        image = str.add_file_prefix_gentle(image)

        local file_wikitext = string.format('[[%s|300px|frameless|center]]', image)

        if caption then
            -- Hover overlay version
            local wrap = td:tag('div'):addClass('ib-image-wrap')
            wrap:wikitext(file_wikitext)

            -- Caption overlay (escape as plain text; avoid interpreting pipes/brackets)
            wrap:tag('div')
                :addClass('ib-image-caption')
                :wikitext(mw.text.nowiki(caption))
        else
            -- Backward-compatible behavior
            td:wikitext(file_wikitext)
        end
    end

    td:done()

    -- Spacer row after
    root:tag('tr')
        :tag('td')
            :addClass('spacer-row')
            :attr('colspan', 2)
        :done()

    return root
end




function ibf.add_row(root, name, value, html_theme_class)
    if not (name and name ~= "" and value and value ~= "") then
        return root
    end
    local row_type_class = 'infobox-row' .. html_theme_class
    
    root:tag('tr')
        :tag('td')
            :addClass('infobox-row')        -- Add base class
            :addClass(row_type_class)       -- Add theme class
            :addClass('infobox-label')
            :wikitext('<b>' .. name .. '</b>')
        :done()
        :tag('td')
            :addClass('infobox-row')        -- Add base class
            :addClass(row_type_class)       -- Add theme class
            :wikitext(value)
        :done()
    
    -- Add spacer...
    root:tag('tr')
        :addClass('infobox-row-spacer')
        :tag('td')
            :attr('colspan', 2)
            :wikitext('&nbsp;')
        :done()
    
    return root
end






function ibf.add_spanning_row(root, text, html_theme_class)
    if text and text ~= '' then
        
        root:tag('tr')
            :tag('td')
                :attr('colspan', '2')
                :addClass('infobox-row')
                :addClass('infobox-row' .. html_theme_class)
                :css('text-align', 'center') -- This line centers the text
                :wikitext(text)
                :done()
        return root
    else 
        return root
    end
end





function ibf.add_list_row(root, label, value, html_theme_class)
    if not (label and label ~= "" and value and value ~= "") then
        return root
    end

    local row_type_class = 'infobox-row' .. html_theme_class

    -- Label row, full width
    root:tag('tr')
        :tag('th')
            :attr('colspan', 2)
            :addClass('infobox-row')
            :addClass(row_type_class)
            :addClass('infobox-label')
            :wikitext(label)
        :done()

    -- Value row, full width
    root:tag('tr')
        :tag('td')
            :attr('colspan', 2)
            :addClass('infobox-row')
            :addClass(row_type_class)
            :wikitext(value)
        :done()

    -- Spacer
    root:tag('tr')
        :addClass('infobox-row-spacer')
        :tag('td')
            :attr('colspan', 2)
            :wikitext('&nbsp;')
        :done()

    return root
end




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