# Module:Convert/wikidata

-- Functions to access Wikidata for Module:Convert.  

local Collection = {}  
Collection.__index = Collection  
do  
function Collection:add(item)  
if item ~= nil then  
self.n = self.n + 1  
self[self.n] = item  
end  
end  
function Collection:join(sep)  
return table.concat(self, sep)  
end  
function Collection:remove(pos)  
if self.n > 0 and (pos == nil or (0 < pos and pos <= self.n)) then  
self.n = self.n - 1  
return table.remove(self, pos)  
end  
end  
function Collection:sort(comp)  
table.sort(self, comp)  
end  
function Collection.new  
return setmetatable({n = 0}, Collection)  
end  
end  

local function strip_to_nil(text)  
-- If text is a non-empty string, return its trimmed content,  
-- otherwise return nothing (empty string or not a string).  
if type(text) == 'string' then  
return text:match('(%S.-)%s*$')  
end  
end  

local function frequency_unit(value, unit_table)  
-- For use when converting m to Hz.  
-- Return true, s where s = name of unit's default output unit,  
-- or return false, t where t is an error message table.  
-- However, for simplicity a valid result is always returned.  
local unit  
if unit_table._symbol == 'm' then  
-- c = speed of light in a vacuum = 299792458 m/s  
-- frequency = c / wavelength  
local w = value * (unit_table.scale or 1)  
local f = 299792458 / w -- if w == 0, f = math.huge which works here  
if f >= 1e12 then  
unit = 'THz'  
elseif f >= 1e9 then  
unit = 'GHz'  
elseif f >= 1e6 then  
unit = 'MHz'  
elseif f >= 1e3 then  
unit = 'kHz'  
else  
unit = 'Hz'  
end  
end  
return true, unit or 'Hz'  
end  

local function wavelength_unit(value, unit_table)  
-- Like frequency_unit but for use when converting Hz to m.  
local unit  
if unit_table._symbol == 'Hz' then  
-- Using 0.9993 rather than 1 avoids rounding which would give results  
-- like converting 300 MHz to 100 cm instead of 1 m.  
local w = 1 / (value * (unit_table.scale or 1)) -- Hz scale is inverted  
if w >= 0.9993e6 then  
unit = 'Mm'  
elseif w >= 0.9993e3 then  
unit = 'km'  
elseif w >= 0.9993 then  
unit = 'm'  
elseif w >= 0.9993e-2 then  
unit = 'cm'  
elseif w >= 0.9993e-3 then  
unit = 'mm'  
else  
unit = 'um'  
end  
end  
return true, unit or 'm'  
end  

local specials = {  
frequency = { frequency_unit },  
wavelength = { wavelength_unit },  
---
-- Following is a removed experiment to show two values as a range  
-- using '-' as the separator.  
-- frequencyrange = { frequency_unit, '-' },  
-- wavelengthrange = { wavelength_unit, '-' },  
}  

local function make_unit(units, parms, uid)  
-- Return a unit code for convert or nil if unit unknown.  
-- If necessary, add a dummy unit to parms so convert will use it  
-- for the input without attempting a conversion since nothing  
-- useful is available (for example, with unit volt).  
local unit = units[uid]  
if type(unit) ~= 'table' then  
return nil  
end  
local ucode = unit.ucode  
if ucode and not unit.si then  
return ucode -- a unit known to convert  
end  
parms.opt_ignore_error = true  
ucode = ucode or unit._ucode -- must be a non-empty string  
local ukey, utable  
if unit.si then  
local base = units[unit.si]  
ukey = base.symbol -- must be a non-empty string  
local n1 = base.name1  
local n2 = base.name2  
if not n1 then  
n1 = ukey  
n2 = n2 or n1 -- do not append 's'  
end  
utable = {  
_symbol = ukey,  
_name1 = n1,  
_name2 = n2,  
link = unit.link or base.link,  
utype = n1,  
prefixes = 1,  
}  
else  
ukey = ucode  
utable = {  
symbol = ucode, -- must be a non-empty string  
name1 = unit.name1, -- if nil, uses symbol  
name2 = unit.name2, -- if nil, uses name1..'s'  
link = unit.link, -- if nil, uses name1  
utype = unit.name1 or ucode,  
}  
end  
utable.scale = 1  
utable.default = ''  
utable.defkey = ''  
utable.linkey = ''  
utable.bad_mcode = ''  
parms.unittable = { [ukey] = utable }  
return ucode  
end  

local function matches_qualifier(statement, qual)  
-- Return:  
-- false, nil : if statement does not match specification  
-- true, nil : if matches, and statement has no qualifier  
-- true, sq : if matches, where sq is the statement's qualifier  
-- A match means that no qualifier was specified (qual == nil), or that  
-- the statement has a qualifier matching the specification.  
-- If a match occurs, the caller needs the statement's qualifier (if any)  
-- so statements that duplicate the qualifier are not used, after the first.  
-- Then, if convert is showing all values for a property such as the diameter  
-- of a telescope's mirror (diameters of primary and secondary mirrors), it  
-- will not show alternative values that could in principle be present for the  
-- same item (telescope) and property (diameter) and qualifier (primary/secondary).  
local target = (statement.qualifiers or {}).P518 -- P518 is "applies to part"  
if type(target) == 'table' then  
for _, q in ipairs(target) do  
if type(q) == 'table' then  
local value = (q.datavalue or {}).value  
if value then  
if qual == nil or qual == value.id then  
return true, value.id  
end  
end  
end  
end  
end  
if qual == nil then  
return true, nil -- only occurs if statement has no qualifier  
end  
return false, nil -- statement's qualifier is not relevant because statement will be skipped  
end  

local function get_statements(parms, pid)  
-- Get specified item and return a list of tables with each statement for property pid.  
-- Each table is of form {statqual=sq, stmt=statement} where sq = statement qualifier (nil if ).  
-- Statements are in Wikidata's order except that those with preferred rank  
-- are first, then normal rank. Any other rank is ignored.  
local stored = {} -- qualifiers of statements that are first for the qualifier, and will be returned  
local qid = strip_to_nil(parms.qid) -- nil for current page's item, or an item id (expensive)  
local qual = strip_to_nil(parms.qual) -- nil or id of wanted P518 (applies to part) item in qualifiers  
local result = Collection.new  
local entity = mw.wikibase.getEntity(qid)  
if type(entity) == 'table' then  
local statements = (entity.claims or {})[pid]  
if type(statements) == 'table' then  
for _, rank in ipairs({ 'preferred', 'normal' }) do  
for _, statement in ipairs(statements) do  
if type(statement) == 'table' and rank == statement.rank then  
local is_match, statqual = matches_qualifier(statement, qual)  
if is_match then  
result:add({ statqual = statqual, stmt = statement })  
end  
end  
end  
end  
end  
end  
return result  
end  

local function input_from_property(tdata, parms, pid)  
-- Given that pid is a Wikidata property identifier like 'P123',  
-- return a collection of {amount, ucode} pairs (two strings)  
-- for each matching item/property, or return nothing.  
---
-- There appear to be few restrictions on how Wikidata is organized so it is  
-- very likely that any decision a module makes about how to handle data  
-- will be wrong for some cases at some time. This meets current requirements.  
-- For each qualifier (or if no qualifier), if there are any preferred  
-- statements, use them and ignore any normal statements.  
-- For each qualifier, for the preferred statements if any, or for  
-- the normal statements (but not both):  
-- * Accept each statement if it has no qualifier (this will not occur  
-- if qual=x is specified because other code already ensures that in that  
-- case, only statements with a qualifier matching x are considered).  
-- * Ignore any statements after the first if it has a qualifier.  
-- The rationale is that for the diameter at South Pole Telescope, want  
-- convert to show the diameters for both the primary and secondary mirrors  
-- if the convert does not specify which diameter is wanted.  
-- However, if convert is given the wanted qualifier, only one value  
-- (_the_ diameter) is wanted. For simplicity/consistency, that is also done  
-- even if no qual=x is specified. Unclear what should happen.  
-- For the wavelength at Nançay Radio Telescope, want to show all three  
-- values, and the values have no qualifiers.  
---
local result = Collection.new  
local done = {}  
local skip_normal  
for _, t in ipairs(get_statements(parms, pid)) do  
local statement = t.stmt  
if statement.mainsnak and statement.mainsnak.datatype == 'quantity' then  
local value = (statement.mainsnak.datavalue or {}).value  
if value then  
local amount = value.amount  
if amount then  
amount = tostring(amount) -- in case amount is ever a number  
if amount:sub(1, 1) == '+' then  
amount = amount:sub(2)  
end  
local unit = value.unit  
if type(unit) == 'string' then  
unit = unit:match('Q%d+$') -- unit item id is at end of URL  
local ucode = make_unit(tdata.wikidata_units, parms, unit)  
if ucode then  
local skip  
if t.statqual then  
if done[t.statqual] then  
skip = true  
else  
done[t.statqual] = true  
end  
else  
if statement.rank == 'preferred' then  
skip_normal = true  
elseif skip_normal then  
skip = true  
end  
end  
if not skip then  
result:add({ amount, ucode })  
end  
end  
end  
end  
end  
end  
end  
return result  
end  

local function input_from_text(tdata, parms, text, insert2)  
-- Given string should be of form "<value><space><unit>" or  
-- "<value1><space>ft<space><value2><space>in" for a special case (feet and inches).  
-- Return true if values/units were extracted and inserted, or return nothing.  
text = text:gsub('&nbsp;', ' '):gsub('%s+', ' ')  
local pos = text:find(' ', 1, true)  
if pos then  
-- Leave checking of value to convert which can handle fractions.  
local value = text:sub(1, pos - 1)  
local uid = text:sub(pos + 1)  
if uid:sub(1, 3) == 'ft ' and uid:sub(-3) == ' in' then  
-- Special case for enwiki to allow  
insert2(uid:sub(4, -4), 'in')  
insert2(value, 'ft')  
else  
insert2(value, make_unit(tdata.wikidata_units, parms, uid) or uid)  
end  
return true  
end  
end  

local function adjustparameters(tdata, parms, index)  
-- For Module:Convert, adjust parms (a table of parameters).  
-- Return true if successful or return false, t where t is an error message table.  
-- This is intended mainly for use in infoboxes where the input might be  
-- <value><space><unit> or  
-- <wikidata-property-id>  
-- If successful, insert values and units in parms, before given index.  
local text = parms.input -- should be a trimmed, non-empty string  
local pid = text:match('^P%d+$')  
local sep = ','  
local special = specials[parms[index]]  
if special then  
parms.out_unit = special[1]  
sep = special[2] or sep  
table.remove(parms, index)  
end  
local function quit  
return false, pid and { 'cvt_no_output' } or { 'cvt_bad_input', text }  
end  
local function insert2(first, second)  
table.insert(parms, index, second)  
table.insert(parms, index, first)  
end  
if pid then  
parms.input_text = '' -- output an empty string if an error occurs  
local result = input_from_property(tdata, parms, pid)  
if result.n == 0 then  
return quit  
end  
local ucode  
for i, t in ipairs(result) do  
-- Convert requires each input unit to be identical.  
if i == 1 then  
ucode = t[2]  
elseif ucode ~= t[2] then  
return quit  
end  
end  
local item = ucode  
if item == parms[index] then  
-- Remove specified output unit if it is the same as the Wikidata unit.  
-- For example, with property "12 km".  
table.remove(parms, index)  
end  
for i = result.n, 1, -1 do  
insert2(result[i][1], item)  
item = sep  
end  
return true  
else  
if input_from_text(tdata, parms, text, insert2) then  
return true  
end  
end  
return quit  
end  

---
--- List units and check syntax of definitions ---------------------------------  
---
local specifications = {  
-- seq = sequence in which fields are displayed  
base = {  
title = 'SI base units',  
fields = {  
symbol = { seq = 2, mandatory = true },  
name1 = { seq = 3, mandatory = true },  
name2 = { seq = 4 },  
link = { seq = 5 },  
},  
noteseq = 6,  
header = '{|\n!si !!symbol !!name1 !!name2 !!link !!note',  
item = '|-\n|%s ||%s ||%s ||%s ||%s ||%s',  
footer = '|}',  
},  
alias = {  
title = 'Aliases for convert',  
fields = {  
ucode = { seq = 2, mandatory = true },  
si = { seq = 3 },  
},  
noteseq = 4,  
header = '{|\n!alias !!ucode !!base !!note',  
item = '|-\n|%s ||%s ||%s ||%s',  
footer = '|}',  
},  
known = {  
title = 'Units known to convert',  
fields = {  
ucode = { seq = 2, mandatory = true },  
label = { seq = 3, mandatory = true },  
},  
noteseq = 4,  
header = '{|\n!qid !!ucode !!label !!note',  
item = '|-\n|%s ||%s ||%s ||%s',  
footer = '|}',  
},  
unknown = {  
title = 'Units not known to convert',  
fields = {  
_ucode = { seq = 2, mandatory = true },  
si = { seq = 3 },  
name1 = { seq = 4 },  
name2 = { seq = 5 },  
link = { seq = 6 },  
label = { seq = 7, mandatory = true },  
},  
noteseq = 8,  
header = '{|\n!qid !!_ucode !!base !!name1 !!name2 !!link !!label !!note',  
item = '|-\n|%s ||%s ||%s ||%s ||%s ||%s ||%s ||%s',  
footer = '|}',  
},  
}  

local function listunits(tdata, ulookup)  
-- For Module:Convert, make wikitext to list the built-in Wikidata units.  
-- Return true, wikitext if successful or return false, t where t is an  
-- error message table. Currently, an error return never occurs.  
-- The syntax of each unit definition is checked and a note is added if  
-- a problem is detected.  
local function safe_cells(t)  
-- This is not currently needed, but in case definitions ever use wikitext  
-- like 'kg', escape the text so it works in a table cell.  
local result = {}  
for i, v in ipairs(t) do  
if v:find('|', 1, true) then  
v = v:gsub('(%[%^%[%-)|(.-%]%])', '%1\0%2') -- replace pipe in piped link with a zero byte  
v = v:gsub('|', '&#124;') -- escape '|'  
v = v:gsub('%z', '|') -- restore pipe in piped link  
end  
result[i] = v:gsub('{', '&#123;') -- escape '{'  
end  
return unpack(result)  
end  
local wdunits = tdata.wikidata_units  
local speckeys = { 'base', 'alias', 'unknown', 'known' }  
for _, sid in ipairs(speckeys) do  
specifications[sid].units = Collection.new  
end  
local keys = Collection.new  
for k, v in pairs(wdunits) do  
keys:add(k)  
end  
table.sort(keys)  
local note_count = 0  
for _, key in ipairs(keys) do  
local unit = wdunits[key]  
local ktext, sid  
if key:match('^Q%d+$') then  
ktext = * .. key .. *  
if unit.ucode then  
sid = 'known'  
else  
sid = 'unknown'  
end  
elseif unit.ucode then  
ktext = key  
sid = 'alias'  
else  
ktext = key  
sid = 'base'  
end  
local result = { ktext }  
local spec = specifications[sid]  
local fields = spec.fields  
local note = Collection.new  
for k, v in pairs(unit) do  
if fields[k] then  
local seq = fields[k].seq  
if result[seq] then  
note:add('duplicate ' .. k) -- cannot happen since keys are unique  
else  
result[seq] = v  
end  
else  
note:add('invalid ' .. k)  
end  
end  
for k, v in pairs(fields) do  
local value = result[v.seq]  
if value then  
if k == 'si' and not wdunits[value] then  
note:add('need si ' .. value)  
end  
if k == 'label' then  
local wdl = mw.wikibase.label(key)  
if wdl ~= value then  
note:add('label changed to ' .. tostring(wdl))  
end  
end  
else  
result[v.seq] = ''  
if v.mandatory then  
note:add('missing ' .. k)  
end  
end  
end  
local text  
if note.n > 0 then  
note_count = note_count + 1  
text = '*' .. note:join('  
')  
end  
result[spec.noteseq] = text or ''  
spec.units:add(result)  
end  
local results = Collection.new  
if note_count > 0 then  
local text = note_count .. (note_count == 1 and ' note' or ' notes')  
results:add("**Search for * to see " .. text .. "**\n")  
end  
for _, sid in ipairs(speckeys) do  
local spec = specifications[sid]  
results:add("**" .. spec.title .. "**")  
results:add(spec.header)  
local fmt = spec.item  
for _, unit in ipairs(spec.units) do  
results:add(string.format(fmt, safe_cells(unit)))  
end  
results:add(spec.footer)  
end  
return true, results:join('\n')  
end  

return { _adjustparameters = adjustparameters, _listunits = listunits }