Jump to content

Module:ConvertIB

Permanently protected module
From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Hike395 (talk | contribs) at 00:52, 4 October 2025 (simplify code). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

require('strict')
local p = {}
local getArgs = require('Module:Arguments').getArgs

-- Function to pull out values and units from numeric args
-- Returns:
--   values:  list of numeric values, or "false" if no numeric argument is given
--   units: list of units (str)
--   value: if there is a last numeric value unpaired with a unit, it becomes the precision
--   anyValue: whether there is a non-false value in the values list
local function parseValuesUnits(args)
	local values = {}
	local units = {}
	local indx = 1
	local value = nil
	local anyValue = false
	-- loop through numeric arguments in pairs
	while args[indx] or args[indx+1] do
		value = args[indx]
		anyValue = anyValue or value
		-- if there is a unit, save in output lists
		if args[indx+1] then
			table.insert(values, value or false)
			table.insert(units, args[indx+1])
			value = nil
		end
		indx = indx+2
	end
	return values, units, value, anyValue
end

-- Function to identify multiple units and rewrite them as new input or output groups
-- Args:
--   values, units: numeric values and units, as lists with same length
-- Returns:
--   newValues, newUnits: same lists rewritten
local function parseMultiples(values, units)
	local newValues = {}
	local newUnits = {}
	local i = 1
	-- we will search for multiples with up to 4 entries (depending on length)
	local maxMultiple = math.min(4,#units-1)
	local valueFound = false -- flag to suppress second (and later) input values
	--- Hack for handling "stone": check if only value supplied is "lb"
	local onlyPounds = true
	for i = 1, #units do
		if values[i] and units[i] ~= 'lb' then
			onlyPounds = false
			break
		end
	end
    local multiple = mw.loadData('Module:ConvertIB/data').multiple
	-- sweep through units
	while i <= #units do
		-- determine index of last possible unit that could contain a multiple
		local last_unit = math.min(i+maxMultiple-1,#units)
		local multipleFound = false
		-- try from longest multiple down to double multiple (prefer longest ones)
		for j = last_unit, i+1, -1 do
			local key = table.concat({unpack(units,i,j)}, '')
			if multiple[key] then
				-- we found a multiple unit
				multipleFound = true
				-- Hack for "stone": add either 'lb' or multiple unit string to output units
				--    depending on whether 'lb' was the only unit string with a value
				if mw.ustring.sub(key,1,2) == 'st' then
					table.insert(newValues, false)
					table.insert(newUnits, onlyPounds and key or 'lb')
				end
				-- if there are any value in the span of the multiple,
				-- then the multiple is an input
				-- assume all missing values after the first are zero
				local firstValueFound = false
				for k = i, j do
					firstValueFound = not valueFound and (firstValueFound or values[k])
					if firstValueFound then
						table.insert(newValues, values[k] or 0)
						table.insert(newUnits, units[k])
					end
				end
				valueFound = valueFound or firstValueFound
				-- if no values in the span of the multiple,
				-- then the multiple is an output. Insert combined string as output unit
				if not firstValueFound then
					table.insert(newValues, false)
					table.insert(newUnits, key)
				end
				i = j+1
				break
			end
		end
		--- If no multiple unit was found, insert value[i] and unit[i] into rewritten lists
		if not multipleFound then
			if valueFound then
				table.insert(newValues, false) -- skip writing value if it is a duplicate
			else
				table.insert(newValues,values[i])
				valueFound = values[i]
			end
			table.insert(newUnits, units[i])
			i = i+1
		end
	end
	return newValues, newUnits			
end

-- Call {{convert}} with args
local function callConvert(args)
	local frame = mw.getCurrentFrame()
	return frame:expandTemplate{title='Convert', args=args}
end

-- Implement {{convinfobox}}
function p._convert(args)
	-- find all values and units in numeric args (and the precision, if it exists)
	local values, units, precision, anyValue = parseValuesUnits(args)
	-- bail if no values at all
	if not anyValue then
		return nil
	end
	-- rewrite values and units if multiple units are found
	values, units = parseMultiples(values, units)
	-- sort input and outputs into different buckets
	local input_values = {}
	local input_units = {}
	local output_units = {}
	for i = 1, #units do
		if values[i] then
			table.insert(input_values, values[i])
			table.insert(input_units, units[i])
		else
			table.insert(output_units, units[i])
		end
	end
	-- bail if nothing to convert
	if #input_values == 0 or #output_units == 0 then
		return nil
	end
	-- assemble argument list to {{convert}}
	local innerArgs = {}
	-- First, pass all input unit(s)
	for i, v in ipairs(input_values) do
		table.insert(innerArgs,v)
		table.insert(innerArgs,input_units[i])
	end
	-- Then the output unit(s) [concatenated as single argument]
	table.insert(innerArgs,table.concat(output_units,"+"))
	if precision then
		table.insert(innerArgs,precision) -- last non-nil value contains precision
	end
	-- now handle all non-numeric arguments, passing to {{convert}}
	innerArgs.abbr = 'on'  -- abbr=on by default
	for k, v in pairs(args) do
		if not tonumber(k) then
			innerArgs[k] = v
		end
	end
    return callConvert(innerArgs)
end

local function impUnitPref(pref,country)
    --- lower case all arguments
    pref = pref and mw.ustring.lower(pref)
    country = country and mw.ustring.lower(country)
    --- determine imperial unit by going thru arguments in priority order
    if pref and pref ~= 'dunam' then
        local impPref = mw.loadData('Module:ConvertIB/data').impPref
        return impPref[pref]
    elseif country then
        return mw.ustring.find(country,"united states",1,true) 
               or mw.ustring.find(country,"united kingdom",1,true)
    end
    return false
end

local largeUnits = {km2=true,mi2=true,sqmi=true,km=true,mi=true}

-- Implement {{Infobox settlement/areadisp}}
function p._area(args)
    local pref = args['pref']
    local country = args['name']
    local impus = impUnitPref(pref, country)
    local dunam = args['dunam'] or args['dunum']
    local link = args['link']
    local innerArgs = {}
    innerArgs.abbr = 'on'
    innerArgs.order = 'out'
    for _, unit in ipairs({'km2','mi2','sqmi','ha','acre'}) do
        if args[unit] then
            table.insert(innerArgs,args[unit])
            table.insert(innerArgs,unit)
            if largeUnits[unit] then
                table.insert(innerArgs,impus and 'sqmi km2' or 'km2 sqmi')
            else
                table.insert(innerArgs,impus and 'acre ha' or 'ha acre')
            end
            return callConvert(innerArgs)
        end
    end
    if dunam then
        table.insert(innerArgs,dunam)
        table.insert(innerArgs,'dunam')
        pref = pref and mw.ustring.lower(pref)
        local order = pref == 'dunam' and 'dunam ' or ''
        dunam = mw.getContentLanguage():parseFormattedNumber(dunam)
        if impus then
            order = order..(dunam and dunam < 2589 and 'acre ha' or 'sqmi km2')
        else
            order = order..(dunam and dunam < 1000 and 'ha acre' or 'km2 sqmi')
        end
        table.insert(innerArgs,order)
        local yesNo = require('Module:Yesno')
        if yesNo(link,true) and link ~= 'none' then
            innerArgs.lk = 'in'
        end
        return callConvert(innerArgs)
    end
    return nil
end

-- Implement {{Infobox settlement/lengthdisp}}
function p._length(args)
    local pref = args['pref']
    if pref == 'dunam' then  -- ignore dunam pref for this function
        pref = nil
    end
    local country = args['name']
    local impus = impUnitPref(pref, country)
    local innerArgs = {}
    innerArgs.abbr = 'on'
    innerArgs.order = 'out'
    for _, unit in ipairs({'km','mi','m','ft'}) do
        if args[unit] then
            table.insert(innerArgs,args[unit])
            table.insert(innerArgs,unit)
            if largeUnits[unit] then
                table.insert(innerArgs,impus and 'mi km' or 'km mi')
            else
                table.insert(innerArgs,impus and 'ft m' or 'm ft')
            end
            return callConvert(innerArgs)
        end
    end
    return nil
end

--Compute number of significant digits in a numeric string
local function computeSigFig(s)
    local num_str = string.match(tostring(s), '^[+-]?[%d%.,]+%d*')
    if not num_str then
       return 0
    end
    
    -- Strip leading signs
    num_str = string.gsub(num_str, '^[+-]', '')
    -- Strip commas
    num_str = string.gsub(num_str, ',', '')
    -- Strip leading zeros
    num_str = string.gsub(num_str, '^0*', '')

    if num_str == '' then
        return 0
    end
    
    -- If there's a decimal point, all trailing zeros are significant.
    if string.find(num_str, '%.') then
        return #string.gsub(num_str, '%.', '') -- Count all digits after removing decimal
    end

    -- If no decimal point, trailing zeros are not significant.
    -- Count all digits up to the last non-zero one.
    num_str = string.gsub(num_str, '0+$', '')
    
    return #num_str
end

-- Implement {{Infobox settlement/densdisp}}
-- Returns table:
--   density = computed value if no error
--   error = error string if error. 
--      These are only errors detected in this code, {{convert}} does its own error handling
function p._density(args)
    local result = {}
    local lang = mw.getContentLanguage()
    local pref = args['pref']
    if pref == 'dunam' then  -- ignore dunam pref for this function
        pref = nil
    end
    local country = args['name']
    local per_km2 = args['/km2']
    local per_mi2 = args['/mi2'] or args['/sqmi']
    local impus = impUnitPref(pref, country, per_km2, per_mi2)
    local per_km2_value = lang:parseFormattedNumber(per_km2)
    local per_mi2_value = lang:parseFormattedNumber(per_mi2)
    local innerArgs = {}
    innerArgs.abbr = 'on'
    if per_km2_value or per_mi2_value then
        innerArgs.order = 'out'
        if per_km2_value then
            table.insert(innerArgs,per_km2_value)
            table.insert(innerArgs,'/km2')
        else
            table.insert(innerArgs,per_mi2_value)
            table.insert(innerArgs,'/sqmi')
        end
        table.insert(innerArgs,impus and '/sqmi /km2' or '/km2 /sqmi')
        result.density = callConvert(innerArgs)
        return result
    end
    if per_km2 ~= 'auto' and per_mi2 ~= 'auto' then
        -- automatic computation not requested, fail silently
        return result
    end
    if not args['pop'] then
        -- fail silently if no population given
        return result
    end
    local areaSigFig
    local areaValue
    local areaUnit
    for _, unit in ipairs({'km2','mi2','sqmi','ha','acre','dunam','dunum'}) do
        local value = lang:parseFormattedNumber(args[unit])
        if value then
            if value <= 0 then
                result.error = unit.." value not positive"
                return result
            end
            areaValue = value
            areaUnit = unit
            areaSigFig = computeSigFig(args[unit])
            break
        elseif args[unit] then
            result.error = "Malformed "..unit.." value"
            return result
        end
    end
    if not areaSigFig then
        -- fail silently if no area given
        return result
    end
    if areaSigFig == 0 then
        result.error = "Malformed area string"
        return result
    end
    local popValue = lang:parseFormattedNumber(args['pop'])
    if not popValue then
        result.error = "Malformed population value"
        return result
    end
    if popValue < 0 then
        result.error = "Negative population value"
        return result
    end
    table.insert(innerArgs,popValue/areaValue)
    table.insert(innerArgs,'/'..areaUnit)
    local popSigFig = computeSigFig(args['pop'])
    local sigFig = popSigFig < areaSigFig and popSigFig or areaSigFig
    if sigFig < 2 then
        sigFig = 2
    end
    innerArgs.sigfig = sigFig
    innerArgs.disp = 'out'
    table.insert(innerArgs,'/km2')
    local metric = callConvert(innerArgs)
    innerArgs[3] = '/sqmi'
    local imperial = callConvert(innerArgs)
    if impus then
        result.density = string.format("%s (%s)",imperial,metric)
    else
        result.density = string.format("%s (%s)",metric,imperial)
    end
    return result
end

function p.convert(frame)
	local args = getArgs(frame)
	return p._convert(args) or ""
end

function p.area(frame)
    local args = getArgs(frame)
    return p._area(args) or ""
end

function p.length(frame)
    local args = getArgs(frame)
    return p._length(args) or ""
end

function p.density(frame)
    local args = getArgs(frame)
    local result = p._density(args)
    if result.density then
        return result.density
    end
    if result.error then
        local warning = require('Module:If_preview')._warning
        local result = warning({result.error})
        if mw.title.getCurrentTitle().namespace == 0 then
            result = result..'[[Category:Pages using infobox settlement with bad density arguments]]'
        end
        return result
    end
    return ''
end

return p