Module:Biobox

Revision as of 01:11, 23 January 2025 by PeaceDeadC (talk | contribs) (Created page with "local p = {} local PBD = require('Module:PBD') local PBDIB = require('Module:PornBaseDataIB') local BioboxUnits = require('Module:BioboxUnits') local GetBirthDate = require('Module:GetBirthDate') local BirthDateAndAge = require('Module:Birth date and age') local Pornbasedata = require('Module:Pornbasedata') local CategoryMapping = require('Module:BioboxCategoryMapping') local ALIAS_TO_PRIMARY = {} do for primary, aliases in pairs(CategoryMapping.PARAM_ALIASES) do...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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

local p = {}

local PBD = require('Module:PBD')
local PBDIB = require('Module:PornBaseDataIB')
local BioboxUnits = require('Module:BioboxUnits')
local GetBirthDate = require('Module:GetBirthDate')
local BirthDateAndAge = require('Module:Birth date and age')
local Pornbasedata = require('Module:Pornbasedata')
local CategoryMapping = require('Module:BioboxCategoryMapping')

local ALIAS_TO_PRIMARY = {}
do
    for primary, aliases in pairs(CategoryMapping.PARAM_ALIASES) do
        for _, alias in ipairs(aliases) do
            ALIAS_TO_PRIMARY[alias] = primary
        end
        ALIAS_TO_PRIMARY[primary] = primary
    end
end

local STYLES = {
    INFOBOX = 'class="infoboxnew" align="left" cellspacing="1" cellpadding="1" style="text-align:center; font-size:88%; line-height:1.5em; white-space:normal;"',
    HEADER = 'style="color:white; text-align:center; border: 1px solid white; background:#657D91; font-size:112%;"',
    LABEL = 'width:40%; padding-left:0.1em; overflow-wrap: break-word; font-weight:normal; text-align:left;',
    SECTION_HEADER = 'style="color:white; text-align:center; border: 1px solid white; background:#657D91; font-size:100%;"',
    ODD_ROW = 'background-color:#F7F7F7;',
    EVEN_ROW = 'background-color:white;'
}

local function isEmpty(value)
    return value == nil or value == ''
end

local function isPositive(value)
    if isEmpty(value) then return false end
    value = tostring(value):lower()
    local positiveValues = {
        ['yes'] = true,
        ['y'] = true,
        ['ya'] = true,
        ['yep'] = true,
        ['true'] = true,
        ['1'] = true
    }
    return positiveValues[value] or false
end

local POSITIVE_ONLY_FIELDS = {
    ['tattoo'] = true
}

local function normalizeKeys(args)
    local normalized = {}
    local seenParams = {}
    local processedAliases = {}
    local firstOccurrence = {}
    
    for key, value in pairs(args) do
        local normalizedKey = tostring(key):gsub(" ", "_"):lower()
        local primaryKey = ALIAS_TO_PRIMARY[normalizedKey] or normalizedKey
        
        if not firstOccurrence[primaryKey] then
            firstOccurrence[primaryKey] = #seenParams + 1
        end
        
        table.insert(seenParams, {
            key = key,
            value = value,
            normalizedKey = normalizedKey,
            primaryKey = primaryKey,
            position = firstOccurrence[primaryKey]
        })
    end
    
    table.sort(seenParams, function(a, b) 
        return a.position < b.position
    end)
    
    for _, param in ipairs(seenParams) do
        local primaryKey = param.primaryKey
        
        if not processedAliases[primaryKey] then
            if not isEmpty(param.value) then
                normalized[primaryKey] = param.value
                processedAliases[primaryKey] = true
            elseif not normalized[primaryKey] then
                normalized[primaryKey] = param.value
            end
        end
    end
    
    return normalized
end

local function isPositive(value)
    if isEmpty(value) then return false end
    value = tostring(value):lower()
    local positiveValues = {
        ['yes'] = true,
        ['y'] = true,
        ['ya'] = true,
        ['yep'] = true,
        ['true'] = true,
        ['1'] = true
    }
    return positiveValues[value] or false
end

local function isNumeric(value)
    if type(value) == "number" then return true end
    if type(value) ~= "string" then return false end
    return tonumber(value) ~= nil
end

local POSITIVE_ONLY_FIELDS = {
    ['tattoo'] = true
}

local function shouldDisplayField(fieldName, value)
    if not isEmpty(value) then
        if POSITIVE_ONLY_FIELDS[fieldName] then
            return isPositive(value)
        end
        return true
    end
    return false
end

local function getPBDIBValue(propertyId, args, options)
    return PBDIB._getValue({
        [1] = propertyId,
        qid = args.qid,
        format = options and options.format,
        fpbd = "ALL"
    })
end

local function getPBDValue(propertyId, args, directValue)
    if isEmpty(directValue) then
        return PBD._property{propertyId, 'raw', 'multilanguage', ['eid'] = args.qid}
    end
    return directValue
end

local function getImage(args)
    if not isEmpty(args.image) then
        return string.format('[[File:%s|256px]]', args.image)
    end

    local qid = args.qid
    if isEmpty(qid) then
        qid = mw.wikibase.getEntityIdForCurrentPage()
    end
    
    if qid then
        local image = PBDIB._getValue({
            [1] = "P470",
            qid = qid,
            rank = "best",
            name = "image",
            fpbd = "ALL",
            osd = "no",
            noicon = "yes",
            maxvals = 1
        })
        
        if not isEmpty(image) then
            return string.format([[%s
            <div style="text-align: center;">%s</div>]], 
            mw.ustring.format(
                '[[File:%s|256px]]',
                image
            ),
            PBDIB._getValue({
                qid = qid,
                fpbd = "ALL",
                osd = "no",
                noicon = "y",
                qual = "P523",
                qualsonly = "yes",
                maxvals = 1
            }) or ''
        )
        end
    end

    return '[[File:NoImageAvailable.png|256px|link=PBC:Images]]'
end

local State = {}

function State:new()
    local state = {}
    setmetatable(state, self)
    self.__index = self
    return state
end

local function calculateAge(birthYear, birthMonth, birthDay, deathYear, deathMonth, deathDay)
    birthYear = tonumber(birthYear)
    deathYear = tonumber(deathYear)
    birthMonth = tonumber(birthMonth)
    deathMonth = tonumber(deathMonth)
    birthDay = tonumber(birthDay)
    deathDay = tonumber(deathDay)
    
    if birthYear and deathYear and birthYear > 0 and deathYear > 0 then
        local age = deathYear - birthYear
        if birthMonth and deathMonth then
            if deathMonth < birthMonth or 
               (deathMonth == birthMonth and birthDay and deathDay and deathDay < birthDay) then
                age = age - 1
            end
        end
        if age >= 0 then
            return string.format(" (aged %d)", age)
        end
    end
    return ""
end

function State:formatBirthDate(args, rowIndex)
    local section = {}
    
    local birthDay = args.birth_day ~= "" and args.birth_day or nil
    local birthMonth = args.birth_month ~= "" and args.birth_month or nil
    local birthYear = args.birth_year ~= "" and args.birth_year or nil
    local birthRef = args.birth_ref
    
    local hasDeathDate = false
    if (args.death_year and args.death_year ~= "")
       or (args.death_month and args.death_month ~= "")
       or (args.death_day and args.death_day ~= "")
    then
        hasDeathDate = true
    elseif args.qid then
        local deathDate = PBD._property{
            [1] = 'P247',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        hasDeathDate = deathDate and deathDate ~= ""
    end

    if not (birthDay or birthMonth or birthYear) and args.qid then
        local birthDate = PBD._property{
            [1] = 'P9',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        
        if birthDate and birthDate ~= "" then
            local frame = mw.getCurrentFrame()
            
            birthDay = GetBirthDate.property(frame:newChild{ args = {
                [1] = 'P9',
                [2] = 'day'  
            }})
            
            birthMonth = GetBirthDate.property(frame:newChild{ args = {
                [1] = 'P9', 
                [2] = 'month'
            }})
            
            birthYear = GetBirthDate.property(frame:newChild{ args = {
                [1] = 'P9',
                [2] = 'year'
            }})
            
            birthDay = birthDay ~= "" and tonumber(birthDay) or nil
            birthMonth = birthMonth ~= "" and tonumber(birthMonth) or nil  
            birthYear = birthYear ~= "" and tonumber(birthYear) or nil
        end
    end

    if not (birthDay or birthMonth or birthYear) then
        return {}, rowIndex
    end

    local birthDateDisplay = ""
    local categories = {}
    local isMainNamespace = mw.title.getCurrentTitle().namespace == 0

    if birthMonth and birthDay and birthYear then
        local frame = mw.getCurrentFrame()
        local success, result = pcall(function()
            local monthTime = {year = 2000, month = tonumber(birthMonth), day = 1}
            local monthName = os.date('%B', os.time(monthTime))
            
            if isMainNamespace then
                categories[#categories + 1] = string.format("[[Category:%s_%s_birthdays]]", monthName, birthDay)
                categories[#categories + 1] = string.format("[[Category:%s_births]]", birthYear)
            end

            if hasDeathDate then
                local basicDate = string.format("[[:Category:%s %s birthdays|%s %s]] [[:Category:%s births|%s]]",
                    monthName, birthDay,
                    birthDay, monthName,
                    birthYear, birthYear
                )
                
                local childFrame = frame:newChild{
                    args = { birthYear, birthMonth, birthDay }
                }
                local zodiacResult = BirthDateAndAge.main(childFrame)
                local zodiacPart = zodiacResult:match("<br>Astrology:.*$")
                
                return basicDate .. (zodiacPart or "")
            else
                local childFrame = frame:newChild{
                    args = { birthYear, birthMonth, birthDay }
                }
                return BirthDateAndAge.main(childFrame)
            end
        end)
        
        if success and result ~= "" then
            birthDateDisplay = result .. table.concat(categories, "")
            
            section[#section + 1] = string.format('|-\n! style="%s %s" | \'\'\'Birth:\'\'\'\n| style="text-align:left;" | %s',
                self:getRowStyle(rowIndex),
                STYLES.LABEL,
                birthDateDisplay
            )
            rowIndex = rowIndex + 1
            return section, rowIndex
        end
        
        local monthTime = {year = 2000, month = tonumber(birthMonth), day = 1}
        local monthName = os.date('%B', os.time(monthTime))
        birthDateDisplay = string.format("[[:Category:%s %s birthdays|%s %s]] [[:Category:%s births|%s]]",
            monthName, birthDay,
            birthDay, monthName,
            birthYear, birthYear
        )
        
        if isMainNamespace then
            categories[#categories + 1] = string.format("[[Category:%s_%s_birthdays]]", monthName, birthDay)
            categories[#categories + 1] = string.format("[[Category:%s_births]]", birthYear)
        end
        
        local zodiacSuccess, zodiacResult = pcall(function()
            return BirthDateAndAge.main{[1]=birthYear, [2]=birthMonth, [3]=birthDay}
        end)
        
        if zodiacSuccess then
            local zodiacPart = zodiacResult:match("<br>Astrology:.*$")
            if zodiacPart then
                birthDateDisplay = birthDateDisplay .. zodiacPart
            end
        end
    
    elseif birthMonth and birthYear then
        local ok, monthName
        local monthTime = {year = 2000, month = tonumber(birthMonth), day = 1}
        ok, monthName = pcall(os.date, '%B', os.time(monthTime))
        
        if ok and monthName then
            birthDateDisplay = string.format("[[:Category:%s birthdays|%s]] [[:Category:%s births|%s]]%s", 
                monthName,
                monthName,
                birthYear,
                birthYear,
                not hasDeathDate and string.format(" (≈%d years)", os.date('%Y') - birthYear) or ""
            )
            if isMainNamespace then
                categories[#categories + 1] = string.format("[[Category:%s births]]", birthYear)
                categories[#categories + 1] = string.format("[[Category:%s birthdays]]", monthName)
            end
        end
    
    elseif birthYear then
        local appendAge = not hasDeathDate and string.format(" (≈%d years)", os.date('%Y') - birthYear) or ""
        
        birthDateDisplay = string.format("[[:Category:%s births|%s]]%s",
            birthYear,
            birthYear,
            appendAge
        )
        if isMainNamespace then
            categories[#categories + 1] = string.format("[[Category:%s births]]", birthYear)
        end
    
    elseif birthMonth and birthDay then
        local monthTime = {year = 2000, month = birthMonth, day = 1}
        local ok, monthName = pcall(os.date, '%B', os.time(monthTime))
        
        if ok and monthName then
            birthDateDisplay = string.format("[[:Category:%s %s birthdays|%s %s]]", 
                monthName, birthDay,
                monthName, birthDay
            )
            if isMainNamespace then
                categories[#categories + 1] = "[[Category:Birth year needed]]"
                categories[#categories + 1] = string.format("[[Category:%s_%s_birthdays]]", monthName, birthDay)
            end
        end
    
        do
            local frame = mw.getCurrentFrame()
            local childFrame = frame:newChild{
                args = {
                    [1] = "",
                    [2] = birthMonth,
                    [3] = birthDay
                }
            }
            local success, zodiacResult = pcall(BirthDateAndAge.main, childFrame)
            if success and type(zodiacResult) == "string" and zodiacResult ~= "" then
                local zodiacPart = zodiacResult:match("<br>Astrology:.*$")
                if zodiacPart then
                    birthDateDisplay = birthDateDisplay .. zodiacPart
                end
            end
        end
    end

    if birthDateDisplay ~= "" then
        if birthRef then
            birthDateDisplay = birthDateDisplay .. birthRef
        end
        
        section[#section + 1] = string.format('|-\n! style="%s %s" | \'\'\'Birth:\'\'\'\n| style="text-align:left;" | %s',
            self:getRowStyle(rowIndex),
            STYLES.LABEL,
            birthDateDisplay .. table.concat(categories, '')
        )
        rowIndex = rowIndex + 1
    end
    
    return section, rowIndex
end

function State:formatPlaceOfBirth(args, rowIndex)
    local section = {}
    local categories = {}
    
    local placeOfBirth = args.place_of_birth
    
    if isEmpty(placeOfBirth) and args.qid then
        local birthplaces = {}
        local entity = mw.wikibase.getEntity(args.qid)
        if entity and entity.claims and entity.claims.P20 then
            for _, statement in pairs(entity.claims.P20) do
                if statement.mainsnak.datavalue and 
                   statement.mainsnak.datavalue.value and 
                   statement.mainsnak.datavalue.value.id then
                    local placeId = statement.mainsnak.datavalue.value.id
                    local sitelink = mw.wikibase.getSitelink(placeId)
                    if sitelink and mw.ustring.match(sitelink, '^' .. mw.site.namespaces[14].name .. ':') then
                        table.insert(categories, '[[' .. sitelink .. ']]')
                    end
                end
            end
        end

        local frame = mw.getCurrentFrame()
        local newFrame = frame:newChild{
            args = {
                property = 'P20',
                entityId = args.qid,
                references = 'yes',
                separator = ', '
            }
        }
        
        local formatResult = Pornbasedata.formatProperty(newFrame)
        if not isEmpty(formatResult) then
            placeOfBirth = formatResult
        end
    end
    
    if not isEmpty(placeOfBirth) then
        local rowStyle = self:getRowStyle(rowIndex)
        section[#section + 1] = string.format(
            '|-\n! style="%s %s" | \'\'\'Place of birth:\'\'\'\n| style="%s text-align:left;" | %s%s',
            rowStyle,
            STYLES.LABEL,
            rowStyle,
            placeOfBirth,
            table.concat(categories, '')
        )
        rowIndex = rowIndex + 1
    end
    
    return section, rowIndex
end

local function processCountryValue(value)
    if not value then return nil end
    
    value = mw.text.trim(value)
    
    if value == '' then return nil end
    
    value = value:gsub('^{{', ''):gsub('}}$', '')
    value = mw.text.trim(value)
    
    return string.format('{{flagcountry|%s}}', value)
end

function State:formatBirthCountry(args, rowIndex)
    local section = {}
    local birthCountry = args.birth_country
    
    if isEmpty(birthCountry) and args.qid then
        birthCountry = PBD._property{
            [1] = 'P144',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        
        if not isEmpty(birthCountry) then
            birthCountry = birthCountry:match("^[^,\n]+")
        end
    end
    
    local displayValue = processCountryValue(birthCountry)
    
    if displayValue then
        local frame = mw.getCurrentFrame()
        local expandedDisplayValue = frame:preprocess(displayValue)

        section[#section + 1] = self:formatField(
            "Country of birth:",
            expandedDisplayValue,
            "birth_country",
            rowIndex
        )
        rowIndex = rowIndex + 1
    end
    
    return section, rowIndex
end

function State:formatCitizenship(args, rowIndex)
    local section = {}
    
    local citizenshipValues = {}
    if not isEmpty(args.citizenship) then
        table.insert(citizenshipValues, args.citizenship)
    end
    
    for i = 2, 4 do
        local param = "citizenship" .. i
        if not isEmpty(args[param]) then
            table.insert(citizenshipValues, args[param])
        end
    end
    
    if #citizenshipValues == 0 and args.qid then
        local pbdValues = PBD._properties{
            [1] = 'P133',
            raw = true,
            multilanguage = true,
            sep = '\n',
            ['eid'] = args.qid
        }
        
        if not isEmpty(pbdValues) then
            for value in pbdValues:gmatch("[^,\n]+") do
                table.insert(citizenshipValues, mw.text.trim(value))
            end
        end
    end
    
    local displayValues = {}
    for _, value in ipairs(citizenshipValues) do
        local processed = processCountryValue(value)
        if processed then
            table.insert(displayValues, processed)
        end
    end
    
    if #displayValues > 0 then
        local concatenated = table.concat(displayValues, "<br>")
        
        local frame = mw.getCurrentFrame()
        local expandedDisplayValue = frame:preprocess(concatenated)
        
        section[#section + 1] = self:formatField(
            "Citizenship:",
            expandedDisplayValue,
            "citizenship",
            rowIndex
        )
        rowIndex = rowIndex + 1
    end
    
    return section, rowIndex
end

function State:formatDeathDate(args, rowIndex)
    local section = {}
    
    local deathDay = args.death_day ~= "" and args.death_day or nil
    local deathMonth = args.death_month ~= "" and args.death_month or nil
    local deathYear = args.death_year ~= "" and args.death_year or nil
    local deathRef = args.death_ref
    
    local birthYear = args.birth_year ~= "" and args.birth_year or nil
    local birthMonth = args.birth_month ~= "" and args.birth_month or nil
    local birthDay = args.birth_day ~= "" and args.birth_day or nil

    if deathYear and tonumber(deathYear) and tonumber(deathYear) <= 0 then
        deathYear = nil
    end

    local hasPBDDeathDate = false
    if not (deathDay or deathMonth or deathYear) and args.qid then
        local deathDate = PBD._property{
            [1] = 'P247',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        
        if deathDate and deathDate ~= "" then
            hasPBDDeathDate = true
            local frame = mw.getCurrentFrame()
            
            local pdbDeathDay = GetBirthDate.property(frame:newChild{ args = {
                [1] = 'P247',
                [2] = 'day'
            }})
            
            local pdbDeathMonth = GetBirthDate.property(frame:newChild{ args = {
                [1] = 'P247',
                [2] = 'month'
            }})
            
            local pdbDeathYear = GetBirthDate.property(frame:newChild{ args = {
                [1] = 'P247',
                [2] = 'year'
            }})
            
            if pdbDeathDay ~= "" then deathDay = tonumber(pdbDeathDay) end
            if pdbDeathMonth ~= "" then deathMonth = tonumber(pdbDeathMonth) end
            if pdbDeathYear ~= "" then deathYear = tonumber(pdbDeathYear) end
            
            if not birthYear or not birthMonth or not birthDay then
                local birthDate = PBD._property{
                    [1] = 'P9',
                    raw = true,
                    multilanguage = true,
                    ['eid'] = args.qid
                }
                
                if birthDate and birthDate ~= "" then
                    if not birthYear then
                        local pdbBirthYear = GetBirthDate.property(frame:newChild{ args = {
                            [1] = 'P9',
                            [2] = 'year'
                        }})
                        if pdbBirthYear ~= "" then birthYear = tonumber(pdbBirthYear) end
                    end
                    
                    if not birthMonth then
                        local pdbBirthMonth = GetBirthDate.property(frame:newChild{ args = {
                            [1] = 'P9',
                            [2] = 'month'
                        }})
                        if pdbBirthMonth ~= "" then birthMonth = tonumber(pdbBirthMonth) end
                    end
                    
                    if not birthDay then
                        local pdbBirthDay = GetBirthDate.property(frame:newChild{ args = {
                            [1] = 'P9',
                            [2] = 'day'
                        }})
                        if pdbBirthDay ~= "" then birthDay = tonumber(pdbBirthDay) end
                    end
                end
            end
        end
    end

    if not (deathDay or deathMonth or deathYear) and not hasPBDDeathDate then
        return {}, rowIndex
    end

    local deathDateDisplay = ""
    local categories = {}
    local isMainNamespace = mw.title.getCurrentTitle().namespace == 0

    if isMainNamespace then
        categories[#categories + 1] = "[[Category:Dead people]]"
    end
    
    local ageAtDeath = calculateAge(birthYear, birthMonth, birthDay, deathYear, deathMonth, deathDay)
    
    if deathMonth and deathDay and deathYear then
        local ok, monthName = pcall(os.date, '%B', os.time({year = 2000, month = tonumber(deathMonth), day = 1}))
        if ok and monthName then
            deathDateDisplay = string.format("[[:Category:%s %s deaths|%s %s]] [[:Category:%s deaths|%s]]%s",
                monthName, deathDay,
                deathDay, monthName,
                deathYear, deathYear,
                ageAtDeath
            )
            if isMainNamespace then
                categories[#categories + 1] = string.format("[[Category:%s deaths]]", deathYear)
                categories[#categories + 1] = string.format("[[Category:%s_%s_deaths]]", monthName, deathDay)
            end
        end
    
    elseif deathMonth and deathYear then
        local ok, monthName = pcall(os.date, '%B', os.time({year = 2000, month = tonumber(deathMonth), day = 1}))
        if ok and monthName then
            deathDateDisplay = string.format("[[:Category:%s deaths|%s]] [[:Category:%s deaths|%s]]%s",
                monthName,
                monthName,
                deathYear, deathYear,
                ageAtDeath
            )
            if isMainNamespace then
                categories[#categories + 1] = string.format("[[Category:%s deaths]]", deathYear)
                categories[#categories + 1] = string.format("[[Category:%s deaths]]", monthName)
            end
        end
    
    elseif deathYear then
        deathDateDisplay = string.format("[[:Category:%s deaths|%s]]%s",
            deathYear, deathYear,
            ageAtDeath
        )
        if isMainNamespace then
            categories[#categories + 1] = string.format("[[Category:%s deaths]]", deathYear)
        end
    
    elseif deathMonth and deathDay then
        local ok, monthName = pcall(os.date, '%B', os.time({year = 2000, month = tonumber(deathMonth), day = 1}))
        if ok and monthName then
            deathDateDisplay = string.format("[[:Category:%s %s deaths|%s %s]]",
                monthName, deathDay,
                monthName, deathDay
            )
            if isMainNamespace then
                categories[#categories + 1] = string.format("[[Category:%s_%s_deaths]]", monthName, deathDay)
            end
        end
    end

    if deathDateDisplay ~= "" then
        if deathRef then
            deathDateDisplay = deathDateDisplay .. deathRef
        end
        
        section[#section + 1] = string.format('|-\n! style="%s %s" | \'\'\'Died:\'\'\'\n| style="text-align:left;" | %s',
            self:getRowStyle(rowIndex),
            STYLES.LABEL,
            deathDateDisplay .. table.concat(categories, '')
        )
        rowIndex = rowIndex + 1

        local deathLocation = nil
        if args.death_location and args.death_location ~= "" then
            deathLocation = args.death_location
        elseif args.qid then
            deathLocation = PBD._property{[1] = 'P23', raw = true, multilanguage = true, ['eid'] = args.qid}
        end
        
        if not isEmpty(deathLocation) then
            section[#section + 1] = string.format('|-\n! style="%s %s" | \'\'\'Place of death:\'\'\'\n| style="text-align:left;" | %s',
                self:getRowStyle(rowIndex),
                STYLES.LABEL,
                deathLocation
            )
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

local function extractNumber(str)
    if type(str) == 'number' then 
        return str 
    end

    if type(str) ~= 'string' or str == '' then 
        return nil 
    end

    local num = tonumber(str)
    if num then return num end
    
    num = str:match("^%s*(%d+%.?%d*)[%s%a]*$")
    if num then return tonumber(num) end
    
    num = str:match("^%s*(%d+%.?%d*)%a*$")
    if num then return tonumber(num) end
    
    num = str:match("^%s*(%d+,%d+)%s*$")
    if num then return tonumber(num:gsub(",", ".")) end
    
    return nil
end

local function getHeight(frame, args)
    local height = args.height
    if BioboxUnits.isValidHeight(height) then
        return BioboxUnits.formatHeight(frame, height, {})
    end
    
    local pbdHeight = PBD._property{[1] = 'P156', raw = true, multilanguage = true, ['eid'] = args.qid}
    local heightNum = BioboxUnits.extractNumber(pbdHeight)
    
    if heightNum then
        return BioboxUnits.formatHeight(frame, heightNum, {})
    end
    
    return 'N/A'
end

local function getWeight(frame, args)
    local weight = args.weight
    if BioboxUnits.isValidWeight(weight) then
        return BioboxUnits.formatWeight(frame, weight, {})
    end
    
    local pbdWeight = PBD._property{[1] = 'P159', raw = true, multilanguage = true, ['eid'] = args.qid}
    local weightNum = BioboxUnits.extractNumber(pbdWeight)
    
    if weightNum then
        return BioboxUnits.formatWeight(frame, weightNum, {})
    end
    
    return 'N/A'
end

local function getPenisSize(frame, args)
    frame = frame or mw.getCurrentFrame()
    
    local penisSize = args['penis_size'] or args['penis size']
    if not isEmpty(penisSize) then
        local childArgs = {}
        childArgs[1] = tostring(penisSize)
        childArgs["break"] = "y"
        childArgs["linkspan"] = "y"
        return BioboxUnits.formatPenisSize(frame:newChild{args = childArgs})
    end
    
    local pbdPenisSize = PBD._property{[1] = 'P161', raw = true, multilanguage = true, ['eid'] = args.qid}
    if not isEmpty(pbdPenisSize) then
        local childArgs = {}
        childArgs[1] = tostring(pbdPenisSize)
        childArgs["break"] = "y"
        childArgs["linkspan"] = "y"
        return BioboxUnits.formatPenisSize(frame:newChild{args = childArgs})
    end
    
    return 'N/A'
end

function State:getRowStyle(index)
    return index % 2 == 1 and STYLES.ODD_ROW or STYLES.EVEN_ROW 
end

function State:splitByComma(str)
    if not str then return {} end
    local result = {}
    for value in str:gmatch("[^,]+") do
        table.insert(result, mw.text.trim(value))
    end
    return result
end

function State:formatCategoryLink(value, prefix, displayText)
    if isEmpty(value) then return nil end
    
    local normalized = mw.text.trim(value):gsub("^%l", string.upper)
    displayText = displayText or normalized
    
    local result = string.format("[[:Category:%s%s|%s]]",
        prefix,
        normalized,
        displayText
    )
    
    if mw.title.getCurrentTitle().namespace == 0 then
        result = result .. string.format("[[Category:%s%s]]",
            prefix,
            normalized
        )
    end
    
    return result
end

function State:collectNumberedParams(args, baseParam)
    local values = {}
    local numbered = {}
    
    if not isEmpty(args[baseParam]) then
        table.insert(values, {index = 0, value = args[baseParam]})
    end
    
    for param, value in pairs(args) do
        local index = param:match("^" .. baseParam .. "(%d+)$")
        if index and not isEmpty(value) then
            index = tonumber(index)
            if index and index > 0 and index < 100 then
                table.insert(values, {index = index, value = value})
            end
        end
    end
    
    table.sort(values, function(a, b) return a.index < b.index end)
    
    local result = {}
    for _, item in ipairs(values) do
        table.insert(result, item.value)
    end
    
    return result
end

function State:formatEthnicity(args, rowIndex)
    local section = {}
    
    local ethnicityValues = self:collectNumberedParams(args, "ethnicity")
    
    if #ethnicityValues == 0 and args.qid then
        local pbdValue = PBD._properties{
            [1] = 'P136',
            raw = true,
            normal = true,
            ['eid'] = args.qid
        }
        if not isEmpty(pbdValue) then
            table.insert(ethnicityValues, pbdValue)
        end
    end
    
    if #ethnicityValues > 0 then
        local displayValue = {}
        
        for _, eth in ipairs(ethnicityValues) do
            local ethnicities = self:splitByComma(eth)
            for _, e in ipairs(ethnicities) do
                if #displayValue > 0 then
                    table.insert(displayValue, ", ")
                end
                
                e = mw.text.trim(e):gsub("^%l", string.upper)
                local result = string.format("[[:Category:%s|%s]]", e, e)
                if mw.title.getCurrentTitle().namespace == 0 then
                    result = result .. string.format("[[Category:%s]]", e)
                end
                table.insert(displayValue, result)
            end
        end
        
        section[#section + 1] = self:formatField("Ethnicity:",
            table.concat(displayValue, ""),
            "ethnicity",
            rowIndex)
        rowIndex = rowIndex + 1
    end
    
    return section, rowIndex
end

function State:formatNationality(args, rowIndex)
    local section = {}
    
    local nationalityValues = self:collectNumberedParams(args, "nationality")
    
    if #nationalityValues == 0 and args.qid then
        local pbdValue = PBD._properties{
            [1] = 'P131',
            raw = true,
            normal = true,
            ['eid'] = args.qid
        }
        if not isEmpty(pbdValue) then
            table.insert(nationalityValues, pbdValue)
        end
    end
    
    if #nationalityValues > 0 then
        local displayValue = {}
        
        for _, nat in ipairs(nationalityValues) do
            local nationalities = self:splitByComma(nat)
            for _, n in ipairs(nationalities) do
                if #displayValue > 0 then
                    table.insert(displayValue, ", ")
                end
                
                n = mw.text.trim(n):gsub("^%l", string.upper)
                local result = string.format("[[:Category:%s|%s]]", n, n)
                if mw.title.getCurrentTitle().namespace == 0 then
                    result = result .. string.format("[[Category:%s]]", n)
                end
                table.insert(displayValue, result)
            end
        end
        
        section[#section + 1] = self:formatField("Nationality:",
            table.concat(displayValue, ""),
            "nationality",
            rowIndex)
        rowIndex = rowIndex + 1
    end
    
    return section, rowIndex
end

function State:formatCareer(args, rowIndex)
    local section = {}
    local isMainNamespace = mw.title.getCurrentTitle().namespace == 0
    
    local careerstart = args.careerstart
    if isEmpty(careerstart) and args.qid then
        careerstart = PBD._property{
            [1] = 'P153',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
    end
    
    if not isEmpty(careerstart) then
        local result = string.format("[[:Category:%s career start|%s]]", careerstart, careerstart)
        if isMainNamespace then
            result = result .. string.format("[[Category:%s career start]]", careerstart)
        end
        
        section[#section + 1] = self:formatField("Career Start:",
            result,
            "careerstart",
            rowIndex)
        rowIndex = rowIndex + 1
    end
    
    local career = args.career
    if isEmpty(career) and args.qid then
        career = PBD._property{
            [1] = 'P154',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
    end
    
    local isActive = false
    if not isEmpty(career) then
        local statusMap = {
            ["A"] = {text = "Active", category = "Currently Active Pornstars", class = "careerstatusactive"},
            ["AS"] = {text = "Active (Straight Only)", category = "Currently Active Pornstars (Straight Only)", class = "careerstatusactive"},
            ["R"] = {text = "Retired", category = "Retired Pornstars", class = "careerstatusretired"},
            ["ACTIVE"] = {text = "Active", category = "Currently Active Pornstars", class = "careerstatusactive"},
            ["RETIRED"] = {text = "Retired", category = "Retired Pornstars", class = "careerstatusretired"}
        }
        
        career = career:upper()
        local status = statusMap[career]
        if status then
            isActive = (career == "A" or career == "AS" or career == "ACTIVE")
            
            local displayValue = string.format('<span class="%s">[[:Category:%s|%s]]</span>',
                status.class,
                status.category,
                status.text
            )
            
            if isMainNamespace then
                displayValue = displayValue .. string.format("[[Category:%s]]",
                    status.category
                )
            end
            
            section[#section + 1] = self:formatField("Career Status:",
                displayValue,
                "career",
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    if not isActive then
        local careerend = args.careerend
        if isEmpty(careerend) and args.qid then
            careerend = PBD._property{
                [1] = 'P155',
                raw = true,
                multilanguage = true,
                ['eid'] = args.qid
            }
        end
        
        if not isEmpty(careerend) then
            local result = string.format("[[:Category:%s career end|%s]]", careerend, careerend)
            if isMainNamespace then
                result = result .. string.format("[[Category:%s career end]]", careerend)
            end
            
            section[#section + 1] = self:formatField("Career End:",
                result,
                "careerend", 
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

function State:formatField(label, value, fieldName, index)
    if isEmpty(value) then
        return ''
    end
    
    if POSITIVE_ONLY_FIELDS[fieldName] and not isPositive(value) then
        return ''
    end
    
    local rowStyle = self:getRowStyle(index)
    
    return string.format('|-\n! style="%s %s" | \'\'\'%s\'\'\'\n| style="%s text-align:left;" | %s', 
        rowStyle,
        STYLES.LABEL,
        label,
        rowStyle,
        value)
end

function State:getPBDCategories(args)
    if mw.title.getCurrentTitle().namespace ~= 0 then
        return {}
    end

    local categories = {}
    
    local qid = args.qid
    if isEmpty(qid) then
        qid = mw.wikibase.getEntityIdForCurrentPage()
    end
    if isEmpty(qid) then
        return {}
    end

    local birthplaceProps = mw.wikibase.getBestStatements(qid, "P20")
    if birthplaceProps and #birthplaceProps > 0 then
        for _, statement in pairs(birthplaceProps) do
            if statement.mainsnak and 
               statement.mainsnak.datavalue and 
               statement.mainsnak.datavalue.value and 
               statement.mainsnak.datavalue.value.id then
                local placeId = statement.mainsnak.datavalue.value.id
                local sitelink = mw.wikibase.getSitelink(placeId)
                if sitelink then
                    sitelink = sitelink:gsub("^" .. mw.site.namespaces[14].name .. ":", "")
                    table.insert(categories, sitelink)
                end
            end
        end
    end

    local birthCountryProps = mw.wikibase.getBestStatements(qid, "P144")
    if birthCountryProps and #birthCountryProps > 0 then
        for _, statement in pairs(birthCountryProps) do
            if statement.mainsnak and 
               statement.mainsnak.datavalue and 
               statement.mainsnak.datavalue.value and 
               statement.mainsnak.datavalue.value.id then
                local countryId = statement.mainsnak.datavalue.value.id
                local sitelink = mw.wikibase.getSitelink(countryId)
                if sitelink then
                    sitelink = sitelink:gsub("^" .. mw.site.namespaces[14].name .. ":", "")
                    table.insert(categories, sitelink)
                end
            end
        end
    end

    local occupationProps = mw.wikibase.getBestStatements(qid, "P124")
    if occupationProps and #occupationProps > 0 then
        for _, statement in pairs(occupationProps) do
            if statement.mainsnak and 
               statement.mainsnak.datavalue and 
               statement.mainsnak.datavalue.value and 
               statement.mainsnak.datavalue.value.id then
                local occupationId = statement.mainsnak.datavalue.value.id
                local sitelink = mw.wikibase.getSitelink(occupationId)
                if sitelink then
                    sitelink = sitelink:gsub("^" .. mw.site.namespaces[14].name .. ":", "")
                    table.insert(categories, sitelink)
                end
            end
        end
    end

    return categories
end

function State:formatPersonalSection(args, title)
    local section = {}
    local rowIndex = 1

    if not args.qid or args.qid == "" then
        args.qid = mw.wikibase.getEntityIdForCurrentPage()
    end

    section[#section + 1] = string.format('{| %s', STYLES.INFOBOX)

    local name = args.name
    if isEmpty(name) then
        name = getPBDValue('P7', args)
        if isEmpty(name) then
            name = title
        end 
    end

    section[#section + 1] = string.format('|-\n! colspan="2" %s | \'\'\'%s\'\'\'', STYLES.HEADER, name)

    local imageHtml = getImage(args)
    local imageCaption = not isEmpty(args.imagecaption) and 
        string.format('<br>\'\'%s\'\'', args.imagecaption) or ''
    section[#section + 1] = string.format('|-\n| colspan="2" style="text-align:center;" | %s%s', 
        imageHtml, imageCaption)

    section[#section + 1] = string.format('|-\n! colspan="2" %s | \'\'\'Personal\'\'\'', STYLES.SECTION_HEADER)

    local personalFields = {
        {label = 'Birth name:', prop = 'P5', param = 'real_name'},
        {label = 'Legal name:', prop = 'P753', param = 'legal_name'},
        {label = 'Native name:', prop = 'P13', param = 'native_name'},
        {label = 'Name in kana:', prop = 'P480', param = 'name_in_kana'},
        {label = 'Aliases:', prop = 'P140', param = 'alias'},
        {label = 'Nicknames:', prop = 'P363', param = 'nicknames'}
    }

    for _, field in ipairs(personalFields) do
        local value = getPBDValue(field.prop, args, args[field.param])
        local fieldLine = self:formatField(field.label, value, field.param, rowIndex)
        if fieldLine ~= '' then
            section[#section + 1] = fieldLine
            rowIndex = rowIndex + 1
        end
    end

    local birthSection, newRowIndex = self:formatBirthDate(args, rowIndex)
    for _, line in ipairs(birthSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex
    
    local placeOfBirthSection, newRowIndex = self:formatPlaceOfBirth(args, rowIndex) 
    for _, line in ipairs(placeOfBirthSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex
    
    local birthCountrySection, newRowIndex = self:formatBirthCountry(args, rowIndex)
    for _, line in ipairs(birthCountrySection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local citizenshipSection, newRowIndex = self:formatCitizenship(args, rowIndex)
    for _, line in ipairs(citizenshipSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local deathSection, newRowIndex = self:formatDeathDate(args, rowIndex)
    for _, line in ipairs(deathSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local ethnicitySection, newRowIndex = self:formatEthnicity(args, rowIndex)
    for _, line in ipairs(ethnicitySection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local nationalitySection, newRowIndex = self:formatNationality(args, rowIndex)
    for _, line in ipairs(nationalitySection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local careerSection, newRowIndex = self:formatCareer(args, rowIndex)
    for _, line in ipairs(careerSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    return table.concat(section, '\n'), rowIndex
end

function State:formatFeatureList(values, featureType)
    if not values or #values == 0 then return nil end
    
    local displayValues = {}
    local addedCategories = {}
    local isMainNamespace = mw.title.getCurrentTitle().namespace == 0
    
    if featureType == 'foreskin' then
        for _, value in ipairs(values) do
            if value then
                local feature = mw.text.trim(value):lower()
                local mapping = CategoryMapping.MAPPINGS[featureType][feature]
                
                if mapping then
                    local result = string.format("[[:Category:%s|%s]]",
                        mapping.category,
                        mapping.display
                    )
                    
                    if isMainNamespace then
                        result = result .. string.format("[[Category:%s]]",
                            mapping.category
                        )
                    end
                    
                    return result
                end
            end
        end
        return nil
    end
    
    for _, value in ipairs(values) do
        if value then
            local features = self:splitByComma(value)
            for _, feature in ipairs(features) do
                feature = mw.text.trim(feature):lower()
                local mapping = CategoryMapping.MAPPINGS[featureType][feature]
                
                if mapping and not addedCategories[mapping.category] then
                    if #displayValues > 0 then
                        table.insert(displayValues, ", ")
                    end
                    
                    local result = string.format("[[:Category:%s|%s]]",
                        mapping.category,
                        mapping.display
                    )
                    
                    if isMainNamespace then
                        result = result .. string.format("[[Category:%s]]",
                            mapping.category
                        )
                    end
                    
                    table.insert(displayValues, result)
                    addedCategories[mapping.category] = true
                end
            end
        end
    end
    
    if #displayValues > 0 then
        return table.concat(displayValues)
    end
    
    return nil
end

function State:formatFeature(args, featureType, rowIndex)
    local section = {}
    
    local values = {}
    
    local baseNames = {
        hair = {'hair', 'hair_color'},
        eye_color = {'eye color', 'eye_color', 'eyes', 'eye'},
        body_type = {'body type', 'body_type', 'body'},
        skin = {'skin', 'skin_color', 'skincomplexion'},
        foreskin = {'foreskin'}
    }
    
    for _, baseName in ipairs(baseNames[featureType]) do
        if not isEmpty(args[baseName]) then
            table.insert(values, args[baseName])
            break
        end
    end
    
    local found = false
    for i = 2, 10 do
        for _, baseName in ipairs(baseNames[featureType]) do
            local param = baseName .. tostring(i)
            if not isEmpty(args[param]) then
                table.insert(values, args[param])
                found = true
                break
            end
        end
        if not found then
            break
        end
        found = false
    end
    
    if #values == 0 and args.qid then
        local success, properties = pcall(function()
            return PBD._properties{
                [1] = CategoryMapping.PROPERTY_IDS[featureType],
                raw = false,
                multilanguage = true,
                sep = '\n',
                ['eid'] = args.qid
            }
        end)

        if success and not isEmpty(properties) then
            for value in properties:gmatch("[^,\n]+") do
                table.insert(values, mw.text.trim(value))
            end
        end
    end
    
    local displayValue = self:formatFeatureList(values, featureType)
    if displayValue then
        section[#section + 1] = self:formatField(CategoryMapping.LABELS[featureType],
            displayValue,
            featureType,
            rowIndex)
        rowIndex = rowIndex + 1
    end
    
    return section, rowIndex
end

function State:formatHair(args, rowIndex)
    local section = {}
    
    local hairValues = self:collectNumberedParams(args, "hair_color")
    
    if #hairValues == 0 and args.qid then
        local pbdValue = PBD._property{
            [1] = 'P162',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        if not isEmpty(pbdValue) then
            table.insert(hairValues, pbdValue)
        end
    end
    
    if #hairValues > 0 then
        local displayValue = self:formatFeatureList(hairValues, 'hair')
        if displayValue then
            section[#section + 1] = self:formatField("Hair:",
                displayValue,
                "hair",
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

function State:formatEyeColor(args, rowIndex)
    local section = {}
    
    local eyeValues = self:collectNumberedParams(args, "eye_color")
    
    if #eyeValues == 0 and args.qid then
        local pbdValue = PBD._property{
            [1] = 'P386',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        if not isEmpty(pbdValue) then
            table.insert(eyeValues, pbdValue)
        end
    end
    
    if #eyeValues > 0 then
        local displayValue = self:formatFeatureList(eyeValues, 'eye_color')
        if displayValue then
            section[#section + 1] = self:formatField("Eye color:",
                displayValue,
                "eye_color",
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

function State:formatBodyType(args, rowIndex)
    local section = {}
    
    local bodyValues = self:collectNumberedParams(args, "body_type")
    
    if #bodyValues == 0 and args.qid then
        local pbdValue = PBD._property{
            [1] = 'P399',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        if not isEmpty(pbdValue) then
            table.insert(bodyValues, pbdValue)
        end
    end
    
    if #bodyValues > 0 then
        local displayValue = self:formatFeatureList(bodyValues, 'body_type')
        if displayValue then
            section[#section + 1] = self:formatField("Body type:",
                displayValue,
                "body_type",
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

function State:formatSkin(args, rowIndex)
    local section = {}
    
    local skinValues = self:collectNumberedParams(args, "skincomplexion")
    
    if #skinValues == 0 and args.qid then
        local pbdValue = PBD._property{
            [1] = 'P421',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        if not isEmpty(pbdValue) then
            table.insert(skinValues, pbdValue)
        end
    end
    
    if #skinValues > 0 then
        local displayValue = self:formatFeatureList(skinValues, 'skin')
        if displayValue then
            section[#section + 1] = self:formatField("Skin:",
                displayValue,
                "skin",
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

function State:formatAssType(args, rowIndex)
    local section = {}
    
    local assTypeValues = self:collectNumberedParams(args, "ass_type")
    
    if #assTypeValues == 0 and args.qid then
        local pbdValue = PBD._property{
            [1] = 'P585',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
        if not isEmpty(pbdValue) then
            table.insert(assTypeValues, pbdValue)
        end
    end
    
    if #assTypeValues > 0 then
        local uniqueCategories = {}
        local displayValues = {}
        local hasMainType = false
        
        local function isMainType(category)
            return category == "Small Ass" or category == "Medium Ass" or category == "Big Ass"
        end
        
        for _, assType in ipairs(assTypeValues) do
            local types = self:splitByComma(assType)
            for _, t in ipairs(types) do
                t = mw.text.trim(t):lower()
                local mapping = CategoryMapping.MAPPINGS.ass_type[t]
                
                if mapping then
                    if isMainType(mapping.category) then
                        if not hasMainType then
                            hasMainType = true
                            uniqueCategories[mapping.category] = true
                        end
                    else
                        uniqueCategories[mapping.category] = true
                    end
                end
            end
        end
        
        for category, _ in pairs(uniqueCategories) do
            if #displayValues > 0 then
                table.insert(displayValues, ", ")
            end
            
            local result = string.format("[[:Category:%s|%s]]", category, category)
            if mw.title.getCurrentTitle().namespace == 0 then
                result = result .. string.format("[[Category:%s]]", category)
            end
            table.insert(displayValues, result)
        end
        
        if #displayValues > 0 then
            section[#section + 1] = self:formatField("Ass type:",
                table.concat(displayValues),
                "ass_type",
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

function State:formatForeskin(args, rowIndex)
    local section = {}
    
    local value = args.foreskin
    if isEmpty(value) and args.qid then
        value = PBD._property{
            [1] = 'P398',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
    end
    
    if not isEmpty(value) then
        local displayValue = self:formatFeatureList({value}, 'foreskin')
        if displayValue then
            section[#section + 1] = self:formatField("Foreskin:",
                displayValue,
                "foreskin",
                rowIndex)
            rowIndex = rowIndex + 1
        end
    end
    
    return section, rowIndex
end

function State:formatChestHair(args, rowIndex)
    local section = {}
    local value = args.chest_hair
    if not value and args.qid then
        value = PBD._property{
            [1] = 'P589',
            raw = true,
            multilanguage = true,
            ['eid'] = args.qid
        }
    end

    if not isEmpty(value) then
        value = mw.ustring.lower(value)
        local mappings = CategoryMapping.MAPPINGS.chest_hair
        local match = nil
        for _, mapping in ipairs(mappings) do
            for _, alias in ipairs(mapping.aliases) do
                if mw.ustring.lower(alias) == value then
                    match = mapping
                    break
                end
            end
            if match then break end
        end

        if match then
            local displayValue = '[[:Category:' .. match.category .. '|' .. match.display .. ']] <small>([[:Category:Chest hair|Legend]])</small>'
            if mw.title.getCurrentTitle().namespace == 0 then
                displayValue = displayValue .. '[[Category:' .. match.category .. ']]'
            end
            
            section[#section + 1] = self:formatField(
                "Chest hair:",
                displayValue,
                "chest_hair",
                rowIndex
            )
            rowIndex = rowIndex + 1
        end
    end

    return section, rowIndex
end

function State:formatBodySection(args, rowIndex)
    local section = {}
    
    section[#section + 1] = string.format('|-\n! colspan="2" %s | \'\'\'Body\'\'\'', STYLES.SECTION_HEADER)
    
    section[#section + 1] = '<tr><td colspan="2" style="text-align:center;">'
    section[#section + 1] = '<div style="width: 100%; border: 1px white; background:#657D91; padding: 1px 0; display: grid; grid-template-columns: auto auto auto; color: white; text-align: center;">'
    section[#section + 1] = '<div>[[File:Height attribute symbol.png|40px|link=Height]]</div>'
    section[#section + 1] = '<div>[[File:Weight attribute symbol.png|40px|link=Weight]]</div>'
    section[#section + 1] = '<div>[[File:Penis size attribute symbol.png|40px|link=Penis size]]</div>'

    local frame = mw.getCurrentFrame()
    local height = getHeight(frame, args)
    local weight = getWeight(frame, args)
    local penisSize = getPenisSize(frame, args)

    section[#section + 1] = string.format(
        '<div>\'\'\'%s\'\'\'</div><div>\'\'\'%s\'\'\'</div><div>\'\'\'%s\'\'\'</div></div></td></tr>', 
        height, weight, penisSize
    )

    local hairSection, newRowIndex = self:formatHair(args, rowIndex)
    for _, line in ipairs(hairSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local eyeSection, newRowIndex = self:formatEyeColor(args, rowIndex)
    for _, line in ipairs(eyeSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local bodySection, newRowIndex = self:formatBodyType(args, rowIndex)
    for _, line in ipairs(bodySection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local skinSection, newRowIndex = self:formatSkin(args, rowIndex)
    for _, line in ipairs(skinSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local assTypeSection, newRowIndex = self:formatAssType(args, rowIndex)
    for _, line in ipairs(assTypeSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local foreskinSection, newRowIndex = self:formatForeskin(args, rowIndex)
    for _, line in ipairs(foreskinSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex
    
    local chestHairSection, newRowIndex = self:formatChestHair(args, rowIndex)
    for _, line in ipairs(chestHairSection) do
        section[#section + 1] = line
    end
    rowIndex = newRowIndex

    local tattooValue = args.tattoo
    if not isEmpty(tattooValue) then
        if isPositive(tattooValue) then
            local displayValue = '[[:Category:Performers with Tattoos|Yes]]'
            if mw.title.getCurrentTitle().namespace == 0 then
                displayValue = displayValue .. '[[Category:Performers with Tattoos]]'
            end
            
            local tattooLine = string.format('|-\n! style="%s %s" | \'\'\'%s\'\'\'\n| style="%s text-align:left;" | %s', 
                self:getRowStyle(rowIndex),
                STYLES.LABEL,
                'Tattoo:',
                self:getRowStyle(rowIndex),
                displayValue)
            
            section[#section + 1] = tattooLine
            rowIndex = rowIndex + 1
        end
    end
    
    return table.concat(section, '\n'), rowIndex
end

function p.main(frame)
    local args = frame.args
    if not next(args) then
        args = frame:getParent().args
    end

    args = normalizeKeys(args)
    
    if not args.qid or args.qid == "" then
        args.qid = mw.wikibase.getEntityIdForCurrentPage()
    end
    
    local title = mw.title.getCurrentTitle().text
    local state = State:new()

    local personalSection, rowIndex = state:formatPersonalSection(args, title)
    local bodySection = state:formatBodySection(args, rowIndex)
    
    local result = personalSection .. '\n' .. bodySection

    if args.qid then
        local pbcLink = mw.wikibase.getSitelink(args.qid)
        if pbcLink then
            result = result .. '\n|-class="stop-section-collapse"\n! colspan="2" style="background:#F0E68C;text-align:center;" | [[File:Pbd.svg | 45px | alt=' .. args.qid .. ' | link=pbd:' .. args.qid .. ']]&nbsp;[[:pbd:' .. args.qid .. '|' .. pbcLink .. ']]&nbsp;([[:pbd:' .. args.qid .. '|' .. args.qid .. ']])'
        end

        local newFrame = {
            args = {
                qid = args.qid,
                wiki = "pbcimagerepo"
            }
        }
        local imageRepoLink = PBDIB.getSiteLink(newFrame)
        
        if imageRepoLink and imageRepoLink ~= '' then
            result = result .. '\n|-class="stop-section-collapse"\n! colspan="2" style="background:#F0E68C;text-align:center;" | [[File:Image-icon.svg | 18px | link=:' .. imageRepoLink .. ']]&nbsp;[[:' .. imageRepoLink .. '| PBC Image Repository]]'
        end

        newFrame.args.wiki = "pbcvideorepo"
        local videoRepoLink = PBDIB.getSiteLink(newFrame)
        
        if videoRepoLink and videoRepoLink ~= '' then
            result = result .. '\n|-class="stop-section-collapse"\n! colspan="2" style="background:#F0E68C;text-align:center;" | [[File:Video-icon.svg | 18px | link=:' .. videoRepoLink .. ']]&nbsp;[[:' .. videoRepoLink .. '| PBC Video Repository]]'
        end
    end

    result = result .. '\n|}'

    local categories = state:getPBDCategories(args)
    if #categories > 0 then
        local categoryStrings = {}
        for _, category in ipairs(categories) do
            table.insert(categoryStrings, '[[Category:' .. category .. ']]')
        end
        result = result .. '\n' .. table.concat(categoryStrings, '\n')
    end

    return result
end

return p