Module:HelperMethods

From MaRDI portal

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

------------------------------------------------------------------------------------
--                                 HelperMethods                                  --
--                                                                                --
-- This module includes a number of helper functions for dealing with lists,      --
-- e.g. in the person template. It is a meta-module, meant to be called from      --
-- other Lua modules, and should not be called directly from #invoke.             --
------------------------------------------------------------------------------------


local M = {}

-- Required modules for SPARQL queries and HTML table generation
local sparql = require('SPARQL')
local mwHtml = require('mw.html')

-- Return a string if an item's title is not available
local titleNotAvailablePageName = "TitleNotAvailable"
function M.titleNotAvailableStr(arg)
	local zbmath_de_number
	if type(arg) == "table" and arg.args then
		-- Called from template (e.g. Template:Publication)
		zbmath_de_number = arg.args[1]
	elseif type(arg) == "string" and arg ~= "" then
		-- Called from another module with a zbmath_de_number
		zbmath_de_number = arg
	end

	if zbmath_de_number then
		return "scientific article; zbMATH DE number " .. zbmath_de_number .. " ([[" .. titleNotAvailablePageName .. "|Why is no real title available?]])"
	else
		return "Title not available ([[" .. titleNotAvailablePageName .. "|Why is no real title available?]])"
	end
end

function M.titleNotAvailableStrPlain(arg)
	local zbmath_de_number
	if type(arg) == "table" and arg.args then
		zbmath_de_number = arg.args[1]
	elseif type(arg) == "string" and arg ~= "" then
		zbmath_de_number = arg
	end

	if zbmath_de_number then
		return "scientific article; zbMATH DE number " .. zbmath_de_number
	else
		return "Title not available"
	end
end

function M.titleNotAvailableReasonLink()
	return "[[" .. titleNotAvailablePageName .. "|Why is no real title available?]]"
end

-- Utility function to trim and lowercase a string
function M.trimAndLower(str)
	if str == nil then return nil end
	str = str:gsub("^%s*(.-)%s*$", "%1")
	return str:lower()
end

-- Utility function to count number of results in JSON answer of the SPQARL query
function M.countElementsInBindings(bindings)
	if not bindings then return 0 end
	local count = 0
	while bindings[count] do
	    count = count + 1
	end
	return count
end


-- This function will replace spaces with + and encode other non-alphanumeric 
-- characters into their percent-encoded representations, making the string 
-- safe to use in a URL.
function M.urlencode(str)
    if str then
        str = string.gsub(str, "\n", "\r\n")
        str = string.gsub(str, "([^%w %-%_%.%~])",
            function (c) return string.format("%%%02X", string.byte(c)) end)
        str = string.gsub(str, " ", "+")
    end
    return str
end

-- Function to convert JSON results into an HTML list
-- Expected bindings: "value" (URL) and "valueLabel" (name), same as convertJsonToCommaSeparatedList
function M.convertJsonToList(jsonResults)
    local resultsString = ""
    local seen = {}

    if jsonResults and jsonResults.results and jsonResults.results.bindings then
        local bindings = jsonResults.results.bindings
        for i = 0, #bindings do
            local binding = bindings[i]
            if binding.value and binding.value.value then
                local uri = binding.value.value

                if not seen[uri] then
                    seen[uri] = true
                    local nameAndLink = "<li>" .. M.makeProfileLink(uri) .. "</li>"
                    resultsString = resultsString .. nameAndLink
                end
            end
        end
    end

    return "<ul>" .. resultsString .. "</ul>"
end


-- Function to convert JSON results into a Lua table
function M.convertJsonToTable(jsonResults)
    local resultsTable = {}
    if jsonResults and jsonResults.results and jsonResults.results.bindings then
        local bindings = jsonResults.results.bindings
        for j = 0, #bindings do
            local row = {}
            for key, value in pairs(bindings[j]) do
                table.insert(row, value.value)
            end
            table.insert(resultsTable, row)
        end
    end
    return resultsTable
end

-- Function to convert JSON results into a Lua table with specified field order
function M.convertJsonToTableOrdered(jsonResults, fieldOrder)
    local resultsTable = {}

    if jsonResults and jsonResults.results and jsonResults.results.bindings then
        local bindings = jsonResults.results.bindings
        -- Iterate through each result row in the bindings
        for j = 0, #bindings do
            local row = {}
            for _, fieldName in ipairs(fieldOrder) do
                -- Extract the value from the current binding
                local value = bindings[j][fieldName]
                if value and value.value then
                    table.insert(row, value.value)
                else
                    -- Use "&nbsp;" for missing or empty fields
                    table.insert(row, '&nbsp;')
                end
            end
            table.insert(resultsTable, row)
        end
    end

    return resultsTable
end

-- Function to convert JSON results into a Lua table with specified field order
function M.convertItemListToTableOrdered(jsonResults)
    local resultsTable = {}
    if jsonResults and jsonResults.results and jsonResults.results.bindings then
        local bindings = jsonResults.results.bindings
        for j = 0, #bindings do
            local row = {}
            for key, value in pairs(bindings[j]) do
                table.insert(row, value.value)
            end
            table.insert(resultsTable, row)
        end
    end
    return resultsTable
end


function M.makeWikiLinkOLD(link, name)
	if string.sub(link, 1, 34) == "https://portal.mardi4nfdi.de/wiki/" then
	   return "[[" .. string.sub(link, 35) .. "|" .. name .. "]]"
	end
	return "[" .. link .. " " .. name .. "]"
end

function M.makeWikiLink(link, name)
    local baseWiki = "https://portal.mardi4nfdi.de/wiki/"
    local baseEntity = "https://portal.mardi4nfdi.de/entity/"

	-- Case 1: Workaround for the "not available" info links -> return external link
    if string.find(name, titleNotAvailablePageName) then
	    return "[" .. link .. " " .. name .. "]"
    end    

    -- Case 2: full wiki URL -> strip prefix
    if string.sub(link, 1, #baseWiki) == baseWiki then
        local page = string.sub(link, #baseWiki + 1):match("^[^%?]+")
        return "[[" .. page .. "|" .. name .. "]]"
    end

    -- Case 3: entity URL -> convert to wiki/Item:Qxxx
    if string.sub(link, 1, #baseEntity) == baseEntity then
        local qid = string.sub(link, #baseEntity + 1)
        return "[[Item:" .. qid .. "|" .. name .. "]]"
    end
    
    -- fallback
    return "[" .. link .. " " .. name .. "]"
end

-------------------------------------------------------------------------------
-- The label in a Wikidata item is subject to vulnerabilities
-- that an attacker might try to exploit.
-- It needs to be 'sanitised' by removing any wikitext before use.
-- If it doesn't exist, return the id for the item
-- a second (boolean) value is also returned, value is true when the label exists
-------------------------------------------------------------------------------
-- Dependencies: none
-------------------------------------------------------------------------------
local labelOrId = function(id, lang)
	if lang == "default" then lang = findLang().code end
	local label
	if lang then
		label = mw.wikibase.getLabelByLang(id, lang)
	else
		label = mw.wikibase.getLabel(id)
	end
	if label then
		return mw.text.nowiki(label), true
	else
		return id, false
	end
end

-- Example usage
-- case1 (happy path profile page exists)
-- =p.makeProfileLink("https://portal.mardi4nfdi.de/entity/Q6775828")
--> [[:Research problem:6775828|dose-response analysis]]
-- case 2 (profile page does not exists)
-- =p.makeProfileLink("https://portal.mardi4nfdi.de/entity/Q6821218")
--> Koble-Corrigan heterogeneity exponent constraint [[Item:Q6821218|(edit)]]
-- case 3 (fallback, external link)
-- =p.makeProfileLink("https://nfdi.de")
--> [https://nfdi.de]
function M.makeProfileLink(link)

    local baseWiki = "https://portal.mardi4nfdi.de/wiki/"
    local baseEntity = "https://portal.mardi4nfdi.de/entity/"

    -- Case 3: entity URL -> convert to wiki/Item:Qxxx
    if string.sub(link, 1, #baseEntity) == baseEntity then
        local itemID = string.sub(link, #baseEntity + 1)
		if itemID == "" then return end
		local sitelink = mw.wikibase.getSitelink(itemID)
		local label = labelOrId(itemID)
		if sitelink then
			return "[[:" .. sitelink .. "|" .. label .. "]]"
		else
			return label .. " [[Item:" .. itemID .. "|(edit)]]"
		end
    end
    -- fallback
    return "[" .. link .. "]"
end
				

-- Function to convert JSON results into a comma-separated string
-- E.g. from a SPQRQL query: local jsonResults = sparql.runQuery(sparqlQuery)
-- Expected: "valueLabel" (name or description) and "value" (URL) 
function M.convertJsonToCommaSeparatedList(jsonResults)
	local resultsString = ""
	
	if jsonResults and jsonResults.results and jsonResults.results.bindings then
        local bindings = jsonResults.results.bindings
        for i = 0, #bindings do
            local binding = bindings[i]
            if binding.valueLabel and binding.valueLabel.value then
                if resultsString ~= "" then
                    resultsString = resultsString .. ", "
                end
                
                -- get value
                local value = binding.value.value

                -- get label
                local label = binding.valueLabel.value
--                if string.find(label, "https://") then
--                	label = "N/A"
--                end
                
                local nameAndLink = "[" .. value .. " " .. label .. "]"

                resultsString = resultsString .. M.makeWikiLink(value, label)
            end
        end
    end

    return resultsString
end


-- Additional function to generate the histogram data
-- colWithYear: Contains the index of the column that contains the year information. Expected format "2023-12-30"
function M.generateHistogramChartFromTable(dataTable, colWithYear)
    local yearCounts = {}
 
    for _, row in ipairs(dataTable) do
        local date = row[colWithYear]
        if date then
            local year = date:sub(1, 4)
            local yearNum = tonumber(year)
            -- Only count valid years (reasonable range)
            if yearNum and yearNum >= 1000 and yearNum <= 2100 then
                yearCounts[year] = (yearCounts[year] or 0) + 1
            end
        end
    end
 
    -- Find min and max year
    local minYear, maxYear = math.huge, -math.huge
    for year in pairs(yearCounts) do
        local y = tonumber(year)
        if y then
            if y < minYear then minYear = y end
            if y > maxYear then maxYear = y end
        end
    end
 
    -- Build complete year range, filling gaps with 0
    local years = {}
    local sortedCounts = {}
    if minYear ~= math.huge then
        for y = minYear, maxYear do
            local yearStr = tostring(y)
            table.insert(years, yearStr)
            table.insert(sortedCounts, yearCounts[yearStr] or 0)
        end
    end
 
    local chartData = {
        type = 'bar',
        data = {
            labels = years,  -- x-axis labels (years)
            datasets = {{
                label = 'Number of Publications',
                data = sortedCounts,  -- y-axis data (counts)
                backgroundColor = 'rgba(54, 162, 235, 0.2)',
                borderColor = 'rgba(54, 162, 235, 1)',
                hoverBackgroundColor = 'red',
                borderWidth = 1
            }}
        },
        options = {
            scales = {
                y = {
                    beginAtZero = true
                }
            }
        }
    }
 
    return chartData
end

-- Function to create a HTML table from a Lua table where columns are merged
-- mergeColumns: contains a list of which columns should be merged, e.g. {{1, 2}, {4}} or {{2, 4}, {1, 3}}
function M.createHtmlTableWithMergedCols(dataTable, headers, mergeColumns, itemprop)
    local htmlTable = mwHtml.create('table')
    htmlTable:addClass('wikitable'):attr('border', '1')
    
    htmlTable:addClass('sortable') -- This line ensures your table has the 'sortable' class

    local headerRow = htmlTable:tag('tr')
    
    -- Use the provided headers
    for _, header in ipairs(headers) do
        headerRow:tag('th'):wikitext(header)
    end

    for _, row in ipairs(dataTable) do
	    if not string.find(row[1], "/entity/statement/") then
	        local dataRow = htmlTable:tag('tr')
	        
	        for _, cols in ipairs(mergeColumns) do
	            local combinedData
	            if #cols == 1 then
	                -- If only one column index is provided, use it as is, default to "N/A" string if nil
	                combinedData = row[cols[1]] or "N/A"
            	    local baseEntity = "https://portal.mardi4nfdi.de/entity/"
	            	if string.sub(combinedData, 1, #baseEntity) == baseEntity then
	                	combinedData = M.makeProfileLink(combinedData)
	            	end
	            else
	                -- If two column indices are provided, merge them, defaulting to "N/A" string if nil
	                local col1Data = row[cols[1]] or "N/A"
	                local col2Data = row[cols[2]] or "N/A"
	                
					-- Check if col1Data is "&nbsp;"
					if col1Data == "&nbsp;" then
					    combinedData = col2Data
					-- Check if both col1Data and col2Data are "&nbsp;"
					elseif col1Data == "&nbsp;" and col2Data == "&nbsp;" then
					    combinedData = "&nbsp;"
					else
					--    combinedData = '[' .. col1Data .. ' ' .. col2Data .. ']'
						combinedData = M.makeWikiLink(col1Data, col2Data)
					end
					if itemprop then
						combinedData = combinedData .. ' <link itemprop="' .. itemprop .. '" href="' .. col1Data .. '"/> '
					end
	            end
	            dataRow:tag('td'):wikitext(combinedData)
	        end
	    end
    end
    
    -- mw.log(htmlTable)
    
	return tostring(htmlTable)
end

-- Helper function to convert table contents to a single string
function tableToString(t)
    local stringParts = {}
    for key, value in pairs(t) do
        if type(value) == 'string' then
            table.insert(stringParts, value)
        elseif type(value) == 'table' then
            -- Recursively convert nested tables to string
            table.insert(stringParts, tableToString(value))
        elseif type(value) == 'number' or type(value) == 'boolean' then
            -- Convert numbers and booleans to string directly
            table.insert(stringParts, tostring(value))
        end
    end
    return table.concat(stringParts, " ") -- Combine with a space as separator
end

-- Function to convert text to JSON valid
function M.validJSON(text)
	    -- Ensure text is always a string (handles nil cases)
    text = tostring(text or "")

    -- Use mw.text.jsonEncode to escape special characters
    return mw.text.jsonEncode(text)
end


-- Function to convert Markdown text to Mediawiki format
function M.markdownToMediawiki(mdText)

    -- Check if the input is a table and convert it
    if type(mdText) == 'table' then
        mdText = tableToString(mdText)
    elseif type(mdText) ~= 'string' then
        error("Expected a string or a table, got " .. type(mdText))
    end

    -- Process the string to convert Markdown to MediaWiki formatting

    -- Replace newline and tab placeholders
    mdText = string.gsub(mdText, "\\N", "\n")
    mdText = string.gsub(mdText, "\\T", "\t")

    -- Convert bold markdown (**text**) to MediaWiki bold ('''text''')
    mdText = string.gsub(mdText, "%*%*(.-)%*%*", "'''%1'''")

    -- Convert markdown links [text](url) to MediaWiki [url text]
    mdText = string.gsub(mdText, "%[(.-)%]%((.-)%)", "[%2 %1]")

    -- Convert headings: match at start of line and beginning of string
    mdText = string.gsub(mdText, "\n### (.-)\n", "\n=== %1 ===\n")
    mdText = string.gsub(mdText, "\n## (.-)\n", "\n== %1 ==\n")
    mdText = string.gsub(mdText, "\n# (.-)\n", "\n= %1 =\n")

    mdText = string.gsub(mdText, "^### (.-)\n", "=== %1 ===\n")
    mdText = string.gsub(mdText, "^## (.-)\n", "== %1 ==\n")
    mdText = string.gsub(mdText, "^# (.-)\n", "= %1 =\n")

    return mdText
end

-- Created a string containing all values for a given property of a given item
function M.getValuesForProperty(frame)
	-- Ensure text is always a string (handles nil cases)
	local target1 = tostring(frame.args[1] or "")
	local PID = tostring(frame.args[2] or "")
	
    local sparqlQuery = [[
PREFIX target1: <https://portal.mardi4nfdi.de/entity/]] .. target1 .. [[>
PREFIX wdt: <https://portal.mardi4nfdi.de/prop/direct/>
PREFIX wd: <https://portal.mardi4nfdi.de/entity/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?value (COALESCE(?valueLabel, STR(?value)) AS ?valueLabel)
WHERE {
    target1: wdt:]] .. PID .. [[ ?value .
    OPTIONAL { ?value rdfs:label ?valueLabel . FILTER(LANG(?valueLabel) = "en") }
}
    ]]
    
    -- mw.log( sparqlQuery )
    
	-- Executing the SPARQL query and retrieving results in JSON format
	local jsonResults = sparql.runQuery(sparqlQuery)
	
	-- mw.logObject(jsonResults)

	-- Handle error in SPARQL query execution
	if jsonResults and jsonResults.error then
    	mw.log("Error in SPARQL query: " .. tostring(jsonResults.error))
    	return nil
	end
	
	return jsonResults
end

-- Returns a list of the values for this property.
-- To test:
--    local HM = require("Module:HelperMethods")
--    local fakeFrame = {args = {[1] = "Q6767920", [2] = "P56"}}
--    result = HM.getValuesForPropertyList(fakeFrame)
--    mw.log(result)
function M.getValuesForPropertyList(frame)
	
	local target1 = frame.args[1]
	local PID = frame.args[2]
	
	target1 = tostring(target1 or "")
    if not target1 or target1 == '' then
        return "No records found"
    end    
    
    local fakeFrame = { args = {[1] = target1, [2] = PID } }
	jsonResults = M.getValuesForProperty( fakeFrame )

	if not jsonResults then
        return "Could not fetch data."
	end
	
	if not jsonResults.results then
        return "Could not fetch any data."
	end
	
	if M.countElementsInBindings(jsonResults.results.bindings) == 0 then
        return "No records found."
	end

	local list = M.convertJsonToCommaSeparatedList(jsonResults)
	
	-- mw.log(licenseList) 
	
    return list

end

--- Checks whether item has P31 (is instance) which contains the given QID 
--- Returns "yes", if that is the case or "no" otherwise
--- Also return "no" if no context is given, e.g. when viewing a template
function M.hasP31(frame)
    local targetQID = frame.args[1] or ''
    
    -- No item context (e.g. viewing template directly) 
    local itemQID = frame:preprocess('{{#invoke:Wd|label|raw}}')
    if not itemQID or itemQID == '' then
        return 'no'
    end
    
    local p31values = frame:preprocess('{{#invoke:Wd|properties|raw|P31}}')
    if not p31values or p31values == '' then
        return 'no'
    end
    
    if string.find(p31values, targetQID, 1, true) then
        return 'yes'
    end
    
    return 'no'
end

--- Returns the numeric QID of the current item (without the Q prefix)
--- Returns '' if no item context is given, e.g. when viewing a template directly
--- Usage: {{#invoke:HelperMethods|getNumericQID}}
function M.getNumericQID(frame)
    local qid = frame:preprocess('{{#invoke:Wd|label|raw}}')
    if not qid or qid == '' then
        return ''
    end
    if qid:sub(1,1) == 'Q' then
        return qid:sub(2)
    end
    return qid
end

-- Applies standard MaRDI chart styling to a histogram chart object
function M.applyChartStyling(histogramChart)
    histogramChart.data.datasets[1].backgroundColor = 'rgba(192, 82, 42, 0.75)'
    histogramChart.data.datasets[1].borderColor = 'rgba(192, 82, 42, 1)'
    histogramChart.data.datasets[1].borderWidth = 0
    histogramChart.data.datasets[1].borderRadius = 5
    histogramChart.data.datasets[1].hoverBackgroundColor = 'rgba(192, 82, 42, 1)'
    histogramChart.data.datasets[1].label = 'Publications'
    histogramChart.options = {
        responsive = true,
        maintainAspectRatio = false,
        layout = {
            padding = { right = 0, left = 0 }
        },
        plugins = {
            legend = { display = false }
        },
        scales = {
            x = {
                grid = { display = false },
                ticks = { color = '#888', font = { size = 12 } },
                offset = true
            },
            y = {
                beginAtZero = true,
                grid = { color = 'rgba(0,0,0,0.06)', drawBorder = false },
                ticks = { color = '#888', font = { size = 12 }, precision = 0 }
            }
        }
    }
    return histogramChart
end

-- Returns the DOIP download URL if the item is stored at MaRDI data store (Q6830870)
-- and has a qualifier FDO component id (P1828).
-- The P1828 value is used as the filename in the URL.
-- Args: frame.args[1] = QID (optional, falls back to current page)
function M.getMaRDIStorageURL(frame)
    local itemId = frame.args[1]
    if not itemId or itemId == '' then
        itemId = mw.wikibase.getEntityIdForCurrentPage()
    end
    if not itemId then return '' end

    local entity = mw.wikibase.getEntity(itemId)
    if not entity then return '' end

    local statements = entity:getBestStatements('P1827')
    if not statements then return '' end

    for _, statement in ipairs(statements) do
        local snak = statement.mainsnak
        if snak.datavalue and
           snak.datavalue.value and
           snak.datavalue.value.id == 'Q6830870' then

            local qualifiers = statement.qualifiers
            if qualifiers and qualifiers.P1828 then
                local filename = qualifiers.P1828[1].datavalue.value
                if filename and filename ~= '' then
                    return 'https://doip.portal.mardi4nfdi.de/doip/retrieve/'
                           .. itemId .. '/' .. filename
                end
            end
        end
    end

    return ''
end

-- Returns the filename (P1828 qualifier value) for the MaRDI data store download.
-- Args: frame.args[1] = QID (optional, falls back to current page)
function M.getMaRDIStorageFilename(frame)
    local itemId = frame.args[1]
    if not itemId or itemId == '' then
        itemId = mw.wikibase.getEntityIdForCurrentPage()
    end
    if not itemId then return '' end

    local entity = mw.wikibase.getEntity(itemId)
    if not entity then return '' end

    local statements = entity:getBestStatements('P1827')
    if not statements then return '' end

    for _, statement in ipairs(statements) do
        local snak = statement.mainsnak
        if snak.datavalue and
           snak.datavalue.value and
           snak.datavalue.value.id == 'Q6830870' then

            local qualifiers = statement.qualifiers
            if qualifiers and qualifiers.P1828 then
                local filename = qualifiers.P1828[1].datavalue.value
                if filename and filename ~= '' then
                    return filename
                end
            end
        end
    end

    return ''
end

return M