Jump to content

Module:SemanticAnnotations

Revision as of 03:27, 31 March 2025 by MarkWD (talk | contribs) (// via Wikitext Extension for VSCode)

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

-- Module:SemanticAnnotations
-- Generates semantic annotations for templates
-- Compatible with Semantic MediaWiki, Semantic Scribunto, Semantic Drilldown, and DynamicPageList3
-- Docs: https://github.com/SemanticMediaWiki/SemanticScribunto/tree/master/docs

local p = {}

-- Private helper function to trim whitespace
local function trim(s)
    return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end

--[[
    Generates semantic annotations using SMW's #set parser function.
    This function now handles both legacy string mappings and new complex mappings.
    
    @param args     - Template parameters table
    @param mappings - Property to parameter mappings in any of these formats:
                      1. Simple string: {["Property"] = "param_name"}
                      2. Object with param: {["Property"] = {param = "param_name"}}
                      3. Object with multiple mappings: {["Property"] = {mappings = [{param = "p1", metadata = {...}}, ...]}}
    @param options  - Configuration options:
                     {
                       visible = false,   -- Show annotations (default: false)
                       prefix = "",       -- Property prefix
                       transform = {},    -- Transform functions: {["Property"] = function(val) ... end}
                       default = {},      -- Default values: {["Property"] = "default"}
                       conditional = {},  -- Conditional mappings: {["Property"] = {param="x", value="y"}}
                     }
    
    @return Wikitext string containing semantic annotations
]]
function p.generateAnnotations(args, mappings, options)
    -- For complex mappings, just delegate to generateEnhancedAnnotations
    if mappings and type(mappings) == "table" then
        for _, mapping in pairs(mappings) do
            if type(mapping) == "table" then
                -- Found at least one complex mapping, use the enhanced function
                return p.generateEnhancedAnnotations(args, mappings, options)
            end
        end
    end
    
    -- If we got here, all mappings are simple string mappings
    args = args or {}
    mappings = mappings or {}
    options = options or {}
    
    -- Set defaults for options
    local visible = options.visible or false
    local prefix = options.prefix or ""
    local transform = options.transform or {}
    local default = options.default or {}
    local conditional = options.conditional or {}
    
    -- Start building the annotation block
    local result = {}
    
    -- Determine if we need the hidden div wrapper
    if not visible then
        table.insert(result, '<div style="display:none;">')
    end
    
    -- Start the #set parser function
    table.insert(result, '  {{#set:')
    
    -- Process all property mappings
    local propertyCount = 0
    
    -- Handle regular property mappings (legacy string-to-string format)
    for property, param in pairs(mappings) do
        -- Only process string params (skip tables which are handled by enhanced function)
        if type(param) == "string" then
            local fullPropertyName = prefix .. property
            local value = args[param]
            
            -- Apply transform if one exists for this property
            if value and transform[property] then
                value = transform[property](value)
            end
            
            -- Use the value if it exists, otherwise use default if provided
            if value and value ~= "" then
                table.insert(result, string.format('    |%s=%s', fullPropertyName, value))
                propertyCount = propertyCount + 1
            elseif default[property] then
                table.insert(result, string.format('    |%s=%s', fullPropertyName, default[property]))
                propertyCount = propertyCount + 1
            end
        end
    end
    
    -- Handle conditional properties
    for property, condition in pairs(conditional) do
        local fullPropertyName = prefix .. property
        if args[condition.param] and args[condition.param] == condition.value then
            table.insert(result, string.format('    |%s=%s', fullPropertyName, condition.target or "true"))
            propertyCount = propertyCount + 1
        end
    end
    
    -- Close the #set parser function
    table.insert(result, '  }}')
    
    -- Close the hidden div if we're using it
    if not visible then
        table.insert(result, '</div>')
    end
    
    -- If no properties were set, return an empty string
    if propertyCount == 0 then
        return ""
    end
    
    -- Join all lines and return
    return table.concat(result, "\n")
end

--[[
    Renders a table using TemplateStructure and adds semantic annotations.
    
    @param args             - Template parameters
    @param config           - TemplateStructure configuration
    @param semanticMappings - Property to parameter mappings
    @param semanticOptions  - Annotation configuration options
    
    @return Rendered template with semantic annotations
]]
function p.renderWithSemantics(args, config, semanticMappings, semanticOptions)
    local TemplateStructure = require('Module:TemplateStructure')
    
    -- Render the table structure
    local renderedTable = TemplateStructure.render(args, config)
    
    -- Generate the semantic annotations
    local annotations = p.generateAnnotations(args, semanticMappings, semanticOptions)
    
    -- Combine and return
    return renderedTable .. "\n" .. annotations
end

-- Allows templates to append semantic annotations directly via transclusion
function p.appendToTemplate(frame)
    local args = frame.args
    local parent = frame:getParent()
    local parentArgs = parent and parent.args or {}
    
    -- Mapping is defined as pairs of properties and parameters
    local mappings = {}
    local i = 1
    
    while args["property" .. i] and args["param" .. i] do
        mappings[args["property" .. i]] = args["param" .. i]
        i = i + 1
    end
    
    -- Extract options
    local options = {
        visible = args.visible == "true",
        prefix = args.prefix or ""
    }
    
    -- Generate and return the annotations
    return p.generateAnnotations(parentArgs, mappings, options)
end

-- Helper function for processing a simple property mapping
local function processSimpleMapping(properties, propertyName, value, transformFunc, defaultValue)
    -- Apply transform if one exists and we have a value
    if value and value ~= "" and transformFunc then
        value = transformFunc(value)
    end
    
    -- Use value if it exists, otherwise use default
    if value and value ~= "" then
        -- If property already exists, convert to array or append
        if properties[propertyName] then
            -- Convert to array if it's the first duplicate
            if type(properties[propertyName]) ~= "table" then
                properties[propertyName] = {properties[propertyName]}
            end
            -- Append new value
            table.insert(properties[propertyName], value)
        else
            -- First value for this property
            properties[propertyName] = value
        end
    elseif defaultValue then
        properties[propertyName] = defaultValue
    end
end

-- Helper function for processing a complex property mapping with metadata
local function processComplexMapping(properties, propertyName, args, mappings, transformFunc)
    for _, mappingEntry in ipairs(mappings) do
        local param = mappingEntry.param
        local metadata = mappingEntry.metadata or {}
        local value = args[param]
        
        -- Process only if value exists
        if value and value ~= "" then
            -- Apply transform if available - make sure we use the right scoped transform function
            if transformFunc then
                -- Apply the transformation
                value = transformFunc(value)
            end
            
            -- Add metadata qualifiers to property name if metadata exists
            local qualifiedProperty = propertyName
            if next(metadata) then
                local qualifiers = {}
                for metaKey, metaValue in pairs(metadata) do
                    table.insert(qualifiers, metaKey .. "=" .. metaValue)
                end
                -- Sort for consistency
                table.sort(qualifiers)
                qualifiedProperty = propertyName .. "#" .. table.concat(qualifiers, ";")
            end
            
            -- Set the property with qualified name
            if properties[qualifiedProperty] then
                -- Convert to array if it's the first duplicate
                if type(properties[qualifiedProperty]) ~= "table" then
                    properties[qualifiedProperty] = {properties[qualifiedProperty]}
                end
                -- Append new value
                table.insert(properties[qualifiedProperty], value)
            else
                -- First value for this property
                properties[qualifiedProperty] = value
            end
        end
        -- No need for goto continue - just continue the loop naturally
    end
end

-- Helper function for adding a simple property to parser function result
local function addSimplePropertyToResult(result, propertyName, value, transformFunc, defaultValue)
    -- Apply transform if one exists and we have a value
    if value and value ~= "" and transformFunc then
        value = transformFunc(value)
    end
    
    -- Use value if it exists, otherwise use default
    if value and value ~= "" then
        table.insert(result, string.format('    |%s=%s', propertyName, value))
        return 1
    elseif defaultValue then
        table.insert(result, string.format('    |%s=%s', propertyName, defaultValue))
        return 1
    end
    
    return 0
end

--[[
    Enhanced version of generateAnnotations that supports complex mappings
    Used as fallback when mw.smw is not available
]]
function p.generateEnhancedAnnotations(args, mappings, options)
    args = args or {}
    mappings = mappings or {}
    options = options or {}
    
    -- Set defaults for options
    local visible = options.visible or false
    local prefix = options.prefix or ""
    local transform = options.transform or {}
    local default = options.default or {}
    local conditional = options.conditional or {}
    
    -- Start building the annotation block
    local result = {}
    
    -- Determine if we need the hidden div wrapper
    if not visible then
        table.insert(result, '<div style="display:none;">')
    end
    
    -- Start the #set parser function
    table.insert(result, '  {{#set:')
    
    -- Process all property mappings
    local propertyCount = 0
    
    -- Generate property sets for parser function
    for property, mapping in pairs(mappings) do
        local fullPropertyName = prefix .. property
        
        -- Handle different mapping types
        if type(mapping) == "string" then
            -- Legacy simple string mapping
            propertyCount = propertyCount + addSimplePropertyToResult(result, 
                fullPropertyName, args[mapping], transform[property], default[property])
        elseif type(mapping) == "table" then
            if mapping.param then
                -- Single mapping with object structure
                propertyCount = propertyCount + addSimplePropertyToResult(result, 
                    fullPropertyName, args[mapping.param], transform[property], default[property])
            elseif mapping.mappings then
                -- Complex mapping with multiple parameters
                for _, mappingEntry in ipairs(mapping.mappings) do
                    local param = mappingEntry.param
                    local metadata = mappingEntry.metadata or {}
                    local value = args[param]
                    
                    -- Process only if value exists
                    if value and value ~= "" then
                        -- Apply transform if available
                        if transform[property] then
                            value = transform[property](value)
                        end
                        
                        -- Add metadata qualifiers to property name if metadata exists
                        local qualifiedProperty = fullPropertyName
                        if next(metadata) then
                            local qualifiers = {}
                            for metaKey, metaValue in pairs(metadata) do
                                table.insert(qualifiers, metaKey .. "=" .. metaValue)
                            end
                            -- Sort for consistency
                            table.sort(qualifiers)
                            qualifiedProperty = fullPropertyName .. "#" .. table.concat(qualifiers, ";")
                        end
                        
                        -- Add the property to result
                        table.insert(result, string.format('    |%s=%s', qualifiedProperty, value))
                        propertyCount = propertyCount + 1
                    end
                end
            end
        end
    end
    
    -- Handle conditional properties (maintain backwards compatibility)
    for property, condition in pairs(conditional) do
        local fullPropertyName = prefix .. property
        if args[condition.param] and args[condition.param] == condition.value then
            table.insert(result, string.format('    |%s=%s', fullPropertyName, condition.target or "true"))
            propertyCount = propertyCount + 1
        end
    end
    
    -- Close the #set parser function
    table.insert(result, '  }}')
    
    -- Close the hidden div if we're using it
    if not visible then
        table.insert(result, '</div>')
    end
    
    -- If no properties were set, return an empty string
    if propertyCount == 0 then
        return ""
    end
    
    -- Join all lines and return
    return table.concat(result, "\n")
end

--[[
    Enhanced version of setSemanticProperties that supports complex mappings
    
    @param args     - Template parameters
    @param mappings - Property to parameter mappings with possible metadata
                      Format can be:
                      1. Simple: {["Property"] = "param_name"}
                      2. Object: {["Property"] = {param = "param_name"}}
                      3. Complex: {["Property"] = {mappings = {{param = "p1", metadata = {...}}, ...}}}
                      4. Subobject: {["Property"] = {is_subobject = true, properties = {property map}, id_prefix = "optional_prefix"}}
    @param options  - Configuration options (same as generateAnnotations)
    
    @return Empty string if set via mw.smw, or generated annotations string
]]
function p.setSemanticProperties(args, mappings, options)
    -- Check if mw.smw is available
    if not mw.smw then
        -- Fall back to enhanced parser function approach
        return p.generateEnhancedAnnotations(args, mappings, options)
    end
    
    options = options or {}
    local transform = options.transform or {}
    local default = options.default or {}
    local prefix = options.prefix or ""
    
    -- Build the property table for mw.smw.set
    local properties = {}
    local semanticOutput = ""
    
    -- Process all mappings
    for property, mapping in pairs(mappings) do
        local fullPropertyName = prefix .. property
        
        -- Determine the type of mapping
        if type(mapping) == "string" then
            -- Legacy simple string mapping
            processSimpleMapping(properties, fullPropertyName, args[mapping], transform[property], default[property])
        elseif type(mapping) == "table" then
            if mapping.is_subobject then
                -- This is a special subobject definition
                
                -- Get the subobject properties map
                local subobjectProperties = mapping.properties or {}
                
                -- Create the actual properties map by processing each property value
                local actualProperties = {}
                
                -- Process each property definition in the subobject
                for subPropName, subPropValue in pairs(subobjectProperties) do
                    if type(subPropValue) == "table" and subPropValue.param then
                        -- Object with param reference
                        local paramName = subPropValue.param
                        
                        -- Get the value from args
                        if args[paramName] and args[paramName] ~= "" then
                            local value = args[paramName]
                            
                            -- Apply transform if one exists for this property
                            if subPropValue.transform and type(subPropValue.transform) == "function" then
                                value = subPropValue.transform(value)
                            end
                            
                            -- Set the property
                            actualProperties[subPropName] = value
                        end
                    elseif type(subPropValue) == "string" then
                        -- Simple string mapping or static value
                        if args[subPropValue] and args[subPropValue] ~= "" then
                            -- It's a parameter reference
                            actualProperties[subPropName] = args[subPropValue]
                        else
                            -- It's a static value
                            actualProperties[subPropName] = subPropValue
                        end
                    end
                end
                
                -- If we have at least one property to set
                if next(actualProperties) then
                    -- Generate a reasonably unique ID for the subobject
                    local idPrefix = mapping.id_prefix or "subobj"
                    local idValue = ""
                    
                    -- Try to use primary property value as part of the ID
                    local primaryProp = mapping.primary_property
                    if primaryProp and actualProperties[primaryProp] then
                        -- Clean the value to use in an ID
                        idValue = tostring(actualProperties[primaryProp]):gsub("[^%w]", "_")
                    else
                        -- Just use an incremental number if no primary property
                        idValue = tostring(os.time() % 10000) .. "_" .. math.random(1000, 9999)
                    end
                    
                    -- Create the full ID
                    local subobjectId = idPrefix .. "_" .. idValue
                    
                    -- Create the subobject
                    if mw.smw then
                        -- Use native SMW API
                        local subobjectResult = mw.smw.subobject({
                            id = subobjectId,       -- Use our generated ID
                            properties = actualProperties
                        })
                        
                        -- If there was an error, append it to output for debugging
                        if type(subobjectResult) == "table" and subobjectResult.error then
                            semanticOutput = semanticOutput .. "\n<!-- SMW Subobject Error: " .. 
                                            tostring(subobjectResult.error) .. " -->"
                        end
                    else
                        -- Parser function fallback
                        semanticOutput = semanticOutput .. "\n" .. 
                                        generateSmwSubobjectFragment(actualProperties, subobjectId)
                    end
                end
            elseif mapping.param then
                -- Single mapping with object structure
                processSimpleMapping(properties, fullPropertyName, args[mapping.param], transform[property], default[property])
            elseif mapping.mappings then
                -- Complex mapping with multiple parameters
                processComplexMapping(properties, fullPropertyName, args, mapping.mappings, transform[property])
            end
        end
    end
    
    -- Only set properties if we have some
    if next(properties) then
        -- Set the properties using the native SMW interface
        local success, result = pcall(function()
            return mw.smw.set(properties)
        end)
        
        -- If successful, return semanticOutput (which might contain subobject results)
        -- If failed, fall back to parser function approach
        if success then
            return semanticOutput
        else
            return p.generateEnhancedAnnotations(args, mappings, options) .. semanticOutput
        end
    end
    
    return semanticOutput
end

-- Helper function to generate #subobject parser function with an ID
function p.generateSmwSubobjectFragment(properties, id)
    local result = '<div style="display:none;">\n  {{#subobject:'
    
    -- Set the ID if available
    if id and id ~= "" then
        result = result .. "|@" .. id
    end
    
    for propName, propValue in pairs(properties) do
        if propValue and propValue ~= "" then
            result = result .. "\n    |" .. propName .. "=" .. propValue
        end
    end
    
    result = result .. "\n  }}\n</div>"
    return result
end

-- For backward compatibility with local reference
generateSmwSubobjectFragment = p.generateSmwSubobjectFragment

return p