Module:Check height

-- Validates height formatting in infoboxes and adds error tracking categories when issues are found

local p = {}

-- Default ranges
local DEFAULT_FT_MIN = 4.10
local DEFAULT_FT_MAX = 6.11
local DEFAULT_CM_MIN = 147
local DEFAULT_CM_MAX = 212

-- Helper function to parse and validate ftin_range parameter
local function parseFtInRange(rangeStr)
    if not rangeStr or rangeStr == '' then
        return DEFAULT_FT_MIN, DEFAULT_FT_MAX
    end
    
    -- Pattern: number (with optional decimal) - number (with optional decimal)
    local minStr, maxStr = rangeStr:match('^(%d+%.%d+)%-(%d+%.%d+)$')
    
    if not minStr or not maxStr then
        return DEFAULT_FT_MIN, DEFAULT_FT_MAX
    end
    
    local minVal = tonumber(minStr)
    local maxVal = tonumber(maxStr)
    
    if not minVal or not maxVal then
        return DEFAULT_FT_MIN, DEFAULT_FT_MAX
    end
    
    -- Validate that decimal part is exactly 2 digits and represents inches (00-11)
    local minInches = minStr:match('%.(%d+)$')
    local maxInches = maxStr:match('%.(%d+)$')
    
    if not minInches or not maxInches then
        return DEFAULT_FT_MIN, DEFAULT_FT_MAX
    end
    
    -- Check exactly 2 digits
    if #minInches ~= 2 or #maxInches ~= 2 then
        return DEFAULT_FT_MIN, DEFAULT_FT_MAX
    end
    
    -- Check range 00-11
    local minInchesNum = tonumber(minInches)
    local maxInchesNum = tonumber(maxInches)
    
    if not minInchesNum or not maxInchesNum or 
       minInchesNum < 0 or minInchesNum > 11 or
       maxInchesNum < 0 or maxInchesNum > 11 then
        return DEFAULT_FT_MIN, DEFAULT_FT_MAX
    end
    
    return minVal, maxVal
end

-- Helper function to parse and validate cm_range parameter
local function parseCmRange(rangeStr)
    if not rangeStr or rangeStr == '' then
        return DEFAULT_CM_MIN, DEFAULT_CM_MAX
    end
    
    -- Pattern: number - number (no decimals allowed)
    local minStr, maxStr = rangeStr:match('^(%d+)%-(%d+)$')
    
    if not minStr or not maxStr then
        return DEFAULT_CM_MIN, DEFAULT_CM_MAX
    end
    
    local minVal = tonumber(minStr)
    local maxVal = tonumber(maxStr)
    
    if not minVal or not maxVal then
        return DEFAULT_CM_MIN, DEFAULT_CM_MAX
    end
    
    return minVal, maxVal
end

-- Helper function to remove fraction HTML markup
local function removeFractionMarkup(str)
    if not str then return str end
    
    -- Remove templatestyles strip marker
    str = str:gsub("\127'\"`UNIQ%-%-templatestyles.-QINU`\"'\127", '')
    
    -- Remove span tags with specific classes while preserving inner content appropriately
    -- For sr-only spans, we want to convert the + to actual +
    str = str:gsub('<span class="sr%-only">%+</span>', '+')
    
    -- Remove the frac wrapper span (keep contents)
    str = str:gsub('<span class="frac">(.-)</span>', '%1')
    
    -- Remove num spans (keep contents)
    str = str:gsub('<span class="num">(.-)</span>', '%1')
    
    -- Remove den spans (keep contents)
    str = str:gsub('<span class="den">(.-)</span>', '%1')
    
    return str
end

-- Helper function to remove references/inline tags
local function removeReferences(str)
    if not str then return str end
    
    -- Pattern for MediaWiki strip markers (references)
    str = str:gsub("\127'\"`UNIQ%-%-ref.-QINU`\"'\127", '')
    
    -- Pattern for inline tags (e.g. citation needed)
    str = str:gsub('%[%[Category:.-%</sup>', '')
    
    return str
end

-- Helper function to extract regular and converted heights
local function extractHeights(str)
    if not str then return nil, nil, false end

    local regular, converted = str:match('^(.-)%s%((.-)%)$')
    
    if not regular or not converted then
        return nil, nil, false
    end
    
    local reconstructed = str:gsub('^' .. regular:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1'), '')

    local expectedRemainder = ' (' .. converted .. ')'
    local actualRemainder = reconstructed

    local remainderCheck = actualRemainder:gsub(converted:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1'), '')
    
    if remainderCheck ~= ' ()' then
        return nil, nil, false
    end
    
    return regular, converted, true
end

-- Helper function to delink wikilinks
local function delink(str)
    if not str then return str end
    return str:gsub("%[%[(.-)%]%]", function(match)
        local pipePos = match:find("|")
        if pipePos then
            return match:sub(pipePos + 1)
        else
            return match
        end
    end)
end

-- Helper function to check for invalid decimal feet/inches format (e.g., "5.5 ft" instead of "5 ft 6 in")
local function hasDecimalImperial(str)
    if not str then return false end
    
    -- Delink the string and convert to lowercase
    local testStr = delink(str)
    local lowerStr = testStr:lower()
    
    -- Replace &nbsp; with regular space
    lowerStr = lowerStr:gsub('&nbsp;', ' ')
    
    -- Regex pattern
    if lowerStr:match('%d[,.]%d%s*foot') or 
       lowerStr:match('%d[,.]%d%s*feet') or 
       lowerStr:match('%d[,.]%d%s*ft') or 
       lowerStr:match("%d%.%d+%s*[′']") or 
       lowerStr:match('%d[,.]%d%s*in') then
        return true
    end
    
    return false
end

-- Helper function to check for parentheses without &nbsp; inside them
local function hasParenWithoutNbsp(str)
    if not str then return false end
    
    -- Remove references and inline tags
    str = removeReferences(str)
    
    -- Find all parenthetical expressions and check if any lack &nbsp;
    for parenContent in str:gmatch('%((.-)%)') do
        if not parenContent:find('&nbsp;') then
            return true
        end
    end
    
    return false
end

-- Validate imperial height format
local function validateImperial(height, ftMin, ftMax)
    if not height then return false end
    
    -- Extract feet and inches parts from the min/max values
    local minFeet = math.floor(ftMin)
    local minInches = math.floor((ftMin - minFeet) * 100 + 0.5)
    local maxFeet = math.floor(ftMax)
    local maxInches = math.floor((ftMax - maxFeet) * 100 + 0.5)
    
    -- Pattern 1: X&nbsp;ft Y&nbsp;in (simple)
    local feet, inches = height:match('^(%d+)&nbsp;ft (%d+)&nbsp;in$')
    if feet and inches then
        feet = tonumber(feet)
        inches = tonumber(inches)
        
        -- Check if within range
        if feet < minFeet or feet > maxFeet then
            return false
        end
        if feet == minFeet and inches < minInches then
            return false
        end
        if feet == maxFeet and inches > maxInches then
            return false
        end
        if inches < 0 or inches > 11 then
            return false
        end
        
        return true
    end
    
    -- Pattern 2a: X&nbsp;ft Y+N&frasl;D&nbsp;in (whole inches plus fraction, e.g., "6 ft 2+1/2 in")
    local feet2, inches2, num2, den2 = height:match('^(%d+)&nbsp;ft (%d+)%+(%d+)&frasl;(%d+)&nbsp;in$')
    if feet2 and inches2 and num2 and den2 then
        feet2 = tonumber(feet2)
        inches2 = tonumber(inches2)
        num2 = tonumber(num2)
        den2 = tonumber(den2)
        
        -- Reject "0+" before fraction (should use pattern 2b instead)
        if inches2 == 0 then
            return false
        end
        
        -- Check if within range
        if feet2 < minFeet or feet2 > maxFeet then
            return false
        end
        if feet2 == minFeet and inches2 < minInches then
            return false
        end
        if feet2 == maxFeet and inches2 > maxInches then
            return false
        end
        if inches2 < 0 or inches2 > 11 then
            return false
        end
        -- Numerator must be at least 1, denominator must be > 1, and num < den
        if num2 < 1 or den2 <= 1 or num2 >= den2 then
            return false
        end
        
        return true
    end
    
    -- Pattern 2b: X&nbsp;ft N&frasl;D&nbsp;in (fraction only, e.g., "6 ft 1/2 in")
    local feet3, num3, den3 = height:match('^(%d+)&nbsp;ft (%d+)&frasl;(%d+)&nbsp;in$')
    if feet3 and num3 and den3 then
        feet3 = tonumber(feet3)
        num3 = tonumber(num3)
        den3 = tonumber(den3)
        
        -- Check if within range (inches = 0 for range checking purposes)
        if feet3 < minFeet or feet3 > maxFeet then
            return false
        end
        if feet3 == minFeet and 0 < minInches then
            return false
        end
        if feet3 == maxFeet and 0 > maxInches then
            return false
        end
        -- Numerator must be at least 1, denominator must be > 1, and num < den
        if num3 < 1 or den3 <= 1 or num3 >= den3 then
            return false
        end
        
        return true
    end
    
    return false
end

-- Validate metric height format (meters)
local function validateMeters(height, cmMin, cmMax)
    if not height then return false end
    
    -- Pattern: X.XX&nbsp;m
    local meters = height:match('^(%d+%.%d+)&nbsp;m$')
    if meters then
        -- Remove decimal point and check value
        local metersNoDot = meters:gsub('%.', '')
        local numericValue = tonumber(metersNoDot)
        if numericValue and numericValue >= cmMin and numericValue <= cmMax then
            return true
        end
    end
    
    return false
end

-- Validate metric height format (centimeters)
local function validateCentimeters(height, cmMin, cmMax)
    if not height then return false end
    
    -- Pattern: XXX&nbsp;cm
    local cm = height:match('^(%d+)&nbsp;cm$')
    if cm then
        local numericValue = tonumber(cm)
        if numericValue and numericValue >= cmMin and numericValue <= cmMax then
            return true
        end
    end
    
    return false
end

-- Validate metric height based on metric parameter
local function validateMetric(height, metricParam, cmMin, cmMax)
    if not height then return false end
    
    metricParam = metricParam or 'both'
    
    if metricParam == 'm' then
        return validateMeters(height, cmMin, cmMax)
    elseif metricParam == 'cm' then
        return validateCentimeters(height, cmMin, cmMax)
    else -- 'both' or default
        return validateMeters(height, cmMin, cmMax) or validateCentimeters(height, cmMin, cmMax)
    end
end

-- Main function
function p.main(frame)
	local arguments = require('Module:Arguments')
    local personHeight = require('Module:Person height')
    local args = arguments.getArgs(frame, {trim = true})
    local parentArgs = frame:getParent().args
    
    -- Get parameters
    local height = args[1] or parentArgs[1] or ''
    local metricParam = args['metric'] or parentArgs['metric'] or 'both'
    local catParam = args['cat'] or parentArgs['cat'] or ''
    local ftinRangeParam = args['ftin_range'] or parentArgs['ftin_range'] or ''
    local cmRangeParam = args['cm_range'] or parentArgs['cm_range'] or ''
    
    -- Parse range parameters
    local ftMin, ftMax = parseFtInRange(ftinRangeParam)
    local cmMin, cmMax = parseCmRange(cmRangeParam)
    
    -- If no height provided, return empty
    if height == '' then
        return ''
    end
    
    -- Check raw input for invalid decimal feet format (e.g., "5.5 ft" instead of "5 ft 6 in")
    if hasDecimalImperial(height) then
        mw.addWarning('<span style="color:#d33">Height format error: The raw height input does not match the expected pattern (feet or inches value uses decimal).</span>')
        if catParam ~= '' then
            return '[[Category:' .. catParam .. ']]'
        end
        return ''
    end
    
    -- Check raw input for parentheses without &nbsp; inside
    if hasParenWithoutNbsp(height) then
        mw.addWarning('<span style="color:#d33">Height format error: The raw height input should not include the converted height in parentheses (this should be calculated automatically or by a template).</span>')
        if catParam ~= '' then
            return '[[Category:' .. catParam .. ']]'
        end
        return ''
    end

    -- Set enforce based on metric parameter (nil if 'both')
    local enforceValue = nil
    if metricParam == 'm' then
        enforceValue = 'm'
    elseif metricParam == 'cm' then
        enforceValue = 'cm'
    end
    
    local heightArgs = {
        [1] = height,
        ['enforce'] = enforceValue,
        ['ri'] = 'cmin'
    }
    
    -- Use pcall to catch any errors from Module:Person height
    local success, formattedHeight = pcall(function()
        return personHeight.main(frame:newChild{ args = heightArgs })
    end)
    
    -- If Module:Person height produced an error, treat as invalid structure
    if not success then
        if catParam ~= '' then
            return '[[Category:' .. catParam .. ']]'
        end
        return ''
    end
    
    -- Remove fraction markup
    local cleanedHeight = removeFractionMarkup(formattedHeight)
    
    -- Trim any trailing whitespace
    cleanedHeight = cleanedHeight:gsub('%s+$', '')
    
    -- Remove references and inline tags
    cleanedHeight = removeReferences(cleanedHeight)
    
    -- Extract regular and converted heights
    local regularHeight, convertedHeight, structureValid = extractHeights(cleanedHeight)
    
    -- Check if structure is valid
    if not structureValid then
        -- Add error category
        mw.addWarning('<span style="color:#d33">Height format error: The height format does not match the expected pattern.</span>')
        if catParam ~= '' then
            return '[[Category:' .. catParam .. ']]'
        end
        return ''
    end
    
    -- Validate the heights
    local regularValid = false
    local convertedValid = false
    
    -- Check if regular height is imperial or metric
    if validateImperial(regularHeight, ftMin, ftMax) then
        regularValid = true
        -- If regular is imperial, converted should be metric
        convertedValid = validateMetric(convertedHeight, metricParam, cmMin, cmMax)
    elseif validateMetric(regularHeight, metricParam, cmMin, cmMax) then
        regularValid = true
        -- If regular is metric, converted should be imperial
        convertedValid = validateImperial(convertedHeight, ftMin, ftMax)
    end
    
    -- If validation failed, add error category
    if not regularValid or not convertedValid then
        if catParam ~= '' then
            -- Create dynamic error message based on current ranges
            local ftMinFeet = math.floor(ftMin)
            local ftMinInches = math.floor((ftMin - ftMinFeet) * 100 + 0.5)
            local ftMaxFeet = math.floor(ftMax)
            local ftMaxInches = math.floor((ftMax - ftMaxFeet) * 100 + 0.5)
            
            local errorMsg = string.format(
                '<span style="color:#d33">Height format error: The height values or format are not within acceptable ranges. Height must be between %d\'%d" and %d\'%d" (%d–%d cm).</span>',
                ftMinFeet, ftMinInches, ftMaxFeet, ftMaxInches, cmMin, cmMax
            )
            
            mw.addWarning(errorMsg)
            return '[[Category:' .. catParam .. ']]'
        end
    end
    
    -- All checks passed, return empty
    return ''
end

return p

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.