Module:Gratisdata Infobox

From Gratispaideia Commons

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

require('Module:No globals') -- used for debugging purposes as it detects cases of unintended global variables
local p = {}
local GratisdataIB = require("Module:GratisdataIB")
local i18n = require( 'Module:Gratisdata Infobox/i18n' ).i18n
local getBestStatements = mw.wikibase.getBestStatements
local frame = mw.getCurrentFrame()

local config = {
	-- toggle/customize infobox features:
	defaultsort = true,
	interwiki = true,
	autocat = true,
	trackingcats = true,
	uploadlink = true,
	sitelinks = true,
	authoritycontrol = true,
	helperlinks = true,
	coordtemplate = 1, -- 0 = none, 1 = Geohack, 2 = Coord
	mapwidth = 250,
	mapheight = 250,
	imagesize = '230x500px',

	-- parameters for GratisdataIB:
	spf = '',        -- suppressfields
	fgd = 'ALL',     -- fetchgratisdata
	osd = 'no',      -- onlysourced
	noicon = 'yes',  -- pencil icon
	gdlinks = 'id',  -- add links to Gratisdata if no label found
	collapse = 10,   -- collapse list of values if too many values
	maxvals = 30,    -- stop fetching Gratisdata after this number of values
}

-- variables set by main():
local ITEM            -- mw.wikibase.entity table
local QID             -- qid of ITEM, e.g. 'Q42'
local CLAIMS          -- ITEM.claims
local ISTAXON         -- whether ITEM is a biological taxon
local INSTANCEOF = {} -- Hash set of ITEM's best "instance of" values
local MYLANG          -- user's languge code
local LANG            -- language object of user's language
local FALLBACKLANGS   -- list containing MYLANG and its fallback languages

-- Can't have more than one {{#coordinates:primary}}, so keep track of count
local primary_coordinates = 0

--- Returns label of given Gratisdata entity in user's language.
--- If label doesn't exist, returns the id as link to Gratisdata.
--- @param id string
--- @param nolink? boolean: Whether to return link to Gratisdata if no label found
local function getLabel( id, nolink )
	local label = mw.wikibase.getLabel( id )
	if label then
		return mw.text.nowiki( label ) -- nowiki to prevent wikitext injection
	elseif nolink then
		return id
	else
		return '[[d:' .. id .. ']]'
	end
end

--- Query Gratisdata entity for the first best value of property _pid_.
--- Returns nil if first best value is novalue or somevalue.
--- Returns nil if entityOrId is neither table nor string.
--- @param entityOrId table|string: getEntity() or qid.
--- @param pid string
--- @return unknown|nil
local function getSingleValue( entityOrId, pid )
	local claim
	if type( entityOrId ) == 'table' then
		claim = entityOrId:getBestStatements( pid )[1]
	elseif type( entityOrId ) == 'string' then
		claim = getBestStatements( entityOrId, pid )[1]
	end
	return claim and claim.mainsnak.datavalue and claim.mainsnak.datavalue.value
end

--- Iterator function over a list of Gratisdata claims/statements
--- @param t table as returned by wikibase.getBestStatements
local function iclaims( t )
	local i = 1
	return function()
		while i <= #t do
			local dv = t[i].mainsnak.datavalue
			local v = dv and dv.value
			i = i + 1
			if v then return v end
		end
	end
end

--- Returns Commons sitelink (full page title), preferably to category
--- @param qid string
--- @return string|nil
local function getCommonsLink( qid )
	local sitelink = mw.wikibase.getSitelink( qid, 'gpcommonswiki' )
	if sitelink and sitelink:sub(1,9) == 'Category:' then
		return sitelink  -- sitelink to category page
	end

	local maincat = getSingleValue( qid, 'P162' )  -- topic's main category
	if maincat and maincat.id then
		local sl = mw.wikibase.getSitelink( maincat.id, 'gpcommonswiki' )
		if sl then return sl end
	end

	local listcat = getSingleValue( qid, 'P163' )  -- category related to list
	if listcat and listcat.id then
		local sl = mw.wikibase.getSitelink( listcat.id, 'gpcommonswiki' )
		if sl then return sl end
	end

	local P164 = getSingleValue( qid, 'P164' )  -- Commons category
	if P164 then
		return 'Category:' .. P164
	end

	return sitelink  -- sitelink to gallery page
end

local getSitelink = (mw.wikibase.getGlobalSiteId() == 'gpcommonswiki') and getCommonsLink or mw.wikibase.getSitelink

--- Returns sitelink to Commons as wikilink or the label of the given Q-item
--- @param qid string
local function getLinkOrLabel( qid )
	local sitelink = getSitelink( qid )
	if sitelink then
		return "[[:" .. sitelink .. "|" .. getLabel( qid, true ) .. "]]"
	else
		return getLabel( qid )
	end
end

--- Renders snak as rich wikitext. Returns nil if snak is nil or false.
--- @param snak table: claim.mainsnak or claim.qualifiers[pid]
local function renderSnak( snak )
	if not snak then return end
	local snaktype = snak.snaktype
	if snaktype == 'value' then
		local datatype = snak.datatype
		local value = snak.datavalue.value
		if datatype == 'wikibase-item' then
			return getLinkOrLabel( value.id )
		else
			return mw.wikibase.formatValue( snak )
		end
	elseif snaktype == 'somevalue' then
		local label = mw.message.new('Wikibase-snakview-variations-somevalue-label'):inLanguage(MYLANG):plain()
		return '<i style="color:#54595d">'..label..'</i>'
	end
end

--- Returns claim whose "language of work or name" (P35) qualifier matches
--- langcode, or nil if none matches.
--- @param claims table as returned by getBestStatements()
--- @param langcode string, e.g. "en"
--- @return unknown|nil
local function getClaimByLang( claims, langcode )
	for _, claim in ipairs( claims or {} ) do
		for _, qual in ipairs( claim.qualifiers and claim.qualifiers['P35'] or {} ) do
			if getSingleValue( qual.datavalue.value.id, 'P159' ) == langcode then
				return claim
			end
		end
	end
end

--- If the given snaks of datatype monolingualtext contain a string in one of
--- the user's fallback languages, the string is returned; otherwise a random
--- string is retuned. The second return value indicates whether finding a
--- string in one of the user's fallback languages was successful.
--- @param snaks table, e.g. claims.qualifiers['P130']
--- @return string?, boolean? success
local function extractMonolingualText( snaks )
	if not snaks or snaks == {} then return end

	-- collect strings into hash table with langcodes as keys
	local monotext = {}
	for _, snak in ipairs( snaks ) do
		local ms = snak.mainsnak or snak
		local v = ms and ms.datavalue and ms.datavalue.value
		if v then
			monotext[v.language] = v.text
		end
	end

	for _, lang in ipairs( FALLBACKLANGS ) do
		if monotext[lang] then return monotext[lang], true end
	end

	-- return random string
	local _, v = next( monotext )
	return v, false
end

--- Parses a string in WikiHiero syntax
local function expandhiero( hiero )
	return frame:callParserFunction{ name = '#tag:hiero', args = {hiero} }
end

--- Returns a string containing two table rows
local function format2rowline( header, content )
	return '<tr><th class="gratisdatainfobox-lcell" style="text-align:left" colspan="2">'..header..'</th></tr><tr><td style="vertical-align:top" colspan="2">'..content..'</td></tr>'
end

--- Returns a string containing a single table row
local function format1rowline( trqid, header, content )
	return '<tr id="'..trqid..'"><th class="gratisdatainfobox-lcell">'..header..'</th><td style="vertical-align:top">'..content..'</td></tr>'
end

--- Returns a string containing the HTML markup for an infobox row.
--- Returns nil if content is empty.
--- @param eid string: ID of Gratisdata entity whose label shall be used as heading
--- @param content string|nil
--- @param mobile? boolean: Set to true to show on devices with narrow screens
local function formatLine( eid, content, mobile )
	if not content or content == '' then return end
	local row = mw.html.create( 'tr' )
	if not mobile then
		row:addClass( 'gdinfo_nomobile' ) -- [[Template:Gratisdata_Infobox/styles.css]]
	end
	row:tag( 'th' )
		:addClass( 'gratisdatainfobox-lcell' )
		:node( LANG:ucfirst( getLabel(eid) ) )
	row:tag( 'td' )
		:node( content )
	return tostring( row )
end

--- Returns unbulleted HTML list if given a sequence table.
--- @param list string[]
local function ubl( list )
	if #list == 0 then return end
	local out = table.concat( list, '</li><li>' )
	return '<div class="plainlist"><ul><li>'..out..'</li></ul></div>'
end

--- Given a language code, returns its databaseId (as used by Gratisdata sitelinks).
--- All databaseIds that a wiki knows are stored in its [[mw:Manual:sites table]].
--- @param langcode string
local function databaseId( langcode )
	local exceptions = {
		['be-tarask'] = 'be_x_old',     -- Belarusian (Taraškievica orthography)
		['bho']       = 'bh',           -- Bhojpuri
		['cbk-zam']   = 'cbk_zam',      -- Chavacano de Zamboanga
		['gsw']       = 'als',          -- Alemannic
		['ike']       = 'iu',           -- Inuktitut
		['lzh']       = 'zh_classical', -- Classical Chinese
		['map-bms']   = 'map_bms',      -- Basa Banyumasan
		['nan']       = 'zh_min_nan',   -- Min Nan Chinese
		['nb']        = 'no',           -- Norwegian Bokmål
		['nds-nl']    = 'nds_nl',       -- Low Saxon
		['mo']        = 'ro',           -- Moldaawisk
		['roa-tara']  = 'roa_tara',     -- Tarantino
		['rup']       = 'roa_rup',      -- Aromanian
		['sgs']       = 'bat_smg',      -- Samogitian
		['vro']       = 'fiu_vro',      -- Võro
		['yue']       = 'zh_yue',       -- Cantonese
		-- I did my best to make this list as comprehensive as possible.
		-- Useful pages for finding exceptions:
		-- [[mw:Manual:$wgExtraLanguageCodes]]
		-- [[meta:Special_language codes]]
		-- 
		-- [[meta:Template:N en/list]]
		-- [[meta:Template:Wikilangcode]]
	}

	local exception = exceptions[langcode]
	if exception then return exception end

	return langcode:gsub("-.*", "") -- delete everything after hyphen
end

--- Wrapper around GratisdataIB. Returns nil if the item has no _pid_ statement.
--- @param pid string: Gratisdata property id
--- @param args? table: arguments for GratisdataIB
--- @return string|nil
local function getValue( pid, args )
	args = args or {}

	-- linking many values harms performance if the value items are big and the sitelink needs to be taken from P162, P163 or P164
	local collapse = args.collapse or config.collapse
	local linked
	if pid ~= 'P736' and pid ~= 'P179' and pid ~= 'P737' then 
		linked = #getBestStatements(args.qid or QID, pid) <= collapse
	end

	return GratisdataIB._getValue{
		pid,
		name = pid,
		qid = args.qid or QID,
		linked = args.linked or linked,
		gdlinks = args.gdlinks or config.gdlinks,
		prefix = args.prefix,
		postfix = args.postfix,
		linkprefix  = ':', -- suppress categorization
		qlinkprefix = ':', -- suppress categorization
		sorted = args.sorted,
		qual = args.qual or 'MOST',
		qualsonly = args.qualsonly,
		maxvals = args.maxvals or config.maxvals,
		postmaxvals = '…',
		collapse = collapse,
		spf = args.spf or config.spf,
		fgd = args.fgd or config.fgd,
		osd = args.osd or config.osd,
		rank = 'best',
		noicon = args.noicon or config.noicon,
		list = args.list or 'Unbulleted list',
		sep = args.sep,
		unitabbr = args.unitabbr,
		df = args.df, -- date format
		plaindate = args.plaindate,
		lang = args.lang,
		gendered = args.gendered,
	}
end

--- Used if no custom logic was specified for pid.
local function defaultFunc( pid, args )
	return formatLine( pid, getValue(pid, args) )
end

local function defaultFuncMobile( pid, args )
	return formatLine( pid, getValue(pid, args), true )
end
local function defaultFuncMobileGendered( pid )
	return formatLine( pid, getValue(pid, {gendered=true}), true )
end

local function getAudio( pid )
	local audiofile = getSingleValue( ITEM, pid )
	return audiofile and formatLine( pid, '[[File:' .. audiofile .. '|100px]]' )
end

local function getAudioByLang( pid )
	local claims = ITEM:getBestStatements( pid )
	local claim = claims[1]
	for i = 1, #FALLBACKLANGS do
		local c = getClaimByLang( claims, FALLBACKLANGS[i] )
		if c then
			claim = c
			break
		end
	end
	local audiofile = claim and claim.mainsnak.datavalue and claim.mainsnak.datavalue.value
	return audiofile and formatLine( pid, '[[File:' .. audiofile .. '|100px]]' )
end

-- Example at [[Category:Thutmosis III]]
local function getHieroglyphs()
	local rows = {}
	for _, v in ipairs( ITEM:getBestStatements('P495') ) do -- name in hiero markup
		local idv = v.mainsnak.datavalue.value
		if v.qualifiers and v.qualifiers['P154'] then
			for _, w in ipairs( v.qualifiers['P154'] ) do
				if w.datavalue then
					local label = getLabel( w.datavalue.value.id )
					rows[#rows+1] = format2rowline( label, expandhiero(idv) )
				end
			end
		else
			rows[#rows+1] = format2rowline( getLabel('Q146', true), expandhiero(idv) )
		end
	end
	return table.concat( rows )
end

--- GratisdataIB arguments for birth and death related properties
local birthdeath_args = { list = '', quals = table.concat({
	'P498',  -- refine date
	'P584',   -- statement is subject of
	'P738',  -- object stated as
	'P82',  -- subject named as
	'P497',  -- nature of statement
	'P160',  -- sourcing circumstances
	'P312',   -- determination method
	'P201',  -- criterion used
	'P678',  -- present in work
	'P739', -- applies to work
}, ',') }

local function getBirth( pid )
	local out = {}
	out[#out+1] = getValue( pid, birthdeath_args )                     -- date
	out[#out+1] = CLAIMS['P27'] and getValue( 'P27', birthdeath_args ) -- place
	out[#out+1] = extractMonolingualText( ITEM:getBestStatements('P131') ) -- name
	return formatLine( pid, table.concat(out, '<br>') )
end

local function getDeath( pid )
	local out = {}
	out[#out+1] = getValue( pid, birthdeath_args )                     -- date
	out[#out+1] = CLAIMS['P516'] and getValue( 'P516', birthdeath_args ) -- place
	return formatLine( pid, table.concat(out, '<br>') )
end

local function getWebsite( pid )
	for _, claim in ipairs( ITEM:getBestStatements(pid) ) do
		local quals = claim.qualifiers
		local url = claim.mainsnak.datavalue and claim.mainsnak.datavalue.value
		if url and not (quals and quals['P175']) then -- no "end time" qualifier
			return '<tr><td colspan=2 style="text-align:center">['..url..' '..getLabel(pid)..']</td></tr>'
		end
	end
end

local function getSignature( pid )
	local img = getSingleValue( ITEM, pid )
	if img then
		local alt = LANG:ucfirst( getLabel(pid, true) )
		return '<tr class="gdinfo_nomobile"><td colspan=2 style="text-align:center"><span class="wpImageAnnotatorControl wpImageAnnotatorCaptionOff">[[File:'..img..'|150px|alt='..alt..']]</span></td></tr>'
		-- equivalent to {{ImageNoteControl | caption=off | type=inline}}
	end
end

--- If ITEM has a pid statement, this behaves exactly like defaultFunc. Otherwise
--- figures out the region that ITEM is in and queries the region item for pid.
--- It finds the region by first checking if ITEM has a regionPid value.
--- Otherwise it takes the region from the P583 (category combines topics)
--- statement of ITEM's main category.
--- Examples at Cat:Health_in_Gabon, Cat:Economy_of_Germany
--- @param pid string, e.g. "P679" for life expectancy
--- @param regionPid string: usually "P14" (country) or "P64" (location)
--- @param collapse number: argument for GratisdataIB
local function getByRegion( pid, regionPid, collapse )
	local region = getSingleValue( ITEM, regionPid )
	if CLAIMS[pid] then
		region = QID
	elseif region then
		region = region.id
	else
		local maincat = getSingleValue( ITEM, 'P162' ) -- topic's main category
		if maincat then
			for topic in iclaims( getBestStatements(maincat.id, 'P583') ) do
				local id = topic.id
				if id ~= 'Q3510' and id ~= 'Q3203' and id ~= 'Q3217' then
					-- assume id is QID of a region if it's not the QID for "health", "education", or "economy"
					region = id
				end
			end
		end
	end
	return region and defaultFunc( pid, {
		qid = region,
		collapse = collapse,
	})
end
local function getByCountry( pid )
	return getByRegion( pid, 'P14', 10 )
end
local function getByLocation( pid )
	return getByRegion( pid, 'P64', 10 )
end
local function getByLocationCollapse4( pid )
	return getByRegion( pid, 'P64', 4 )
end

local function getPrimeFactors()
	local out = {}
	for _, claim in ipairs( ITEM:getBestStatements('P681') ) do
		local quals = claim.qualifiers and claim.qualifiers['P181']
		local quantity = quals and quals[1].datavalue.value.amount
		if quantity then
			quantity = quantity:sub(2)  -- strip plus sign
			out[#out+1] = renderSnak(claim.mainsnak) .. '<sup>'..quantity..'</sup>'
		else
			out[#out+1] = renderSnak(claim.mainsnak)
		end
	end
	return formatLine( 'Q3511', table.concat(out, ' × ') )
end

local function getUnicodeChars( pid )
	local rows = {}
	for _, v in ipairs( ITEM:getBestStatements(pid) ) do
		local idv = v.mainsnak.datavalue.value
		for _, w in ipairs( v.qualifiers and v.qualifiers['P154'] or {} ) do
			if w.datavalue then
				local qualid = w.datavalue.value.id
				rows[#rows+1] = format1rowline( qualid, getLabel(qualid), idv )
			end
		end
	end
	return table.concat( rows )
end

local function getCodes( pid )
	local rows = {}
	for _, v in ipairs( ITEM:getBestStatements(pid) ) do
		local idv = v.mainsnak.datavalue.value
		for _, w in ipairs( v.qualifiers and v.qualifiers['P585'] or {} ) do
			if w.datavalue then
				local qualid = w.datavalue.value.id
				if qualid == "Q2993" then
					idv = expandhiero( idv )
				end
				rows[#rows+1] = format1rowline( qualid, getLinkOrLabel(qualid), idv )
			end
		end
	end
	return table.concat( rows )
end

local function getCodeImages( pid )
	local rows = {}
	for _, v in ipairs( ITEM:getBestStatements(pid) ) do
		local idv = v.mainsnak.datavalue.value
		for _, w in ipairs( v.qualifiers and v.qualifiers['P585'] or {} ) do
			if w.datavalue then
				local qualid = w.datavalue.value.id
				local img = '[[File:' .. idv .. '|none|35px]]'
				rows[#rows+1] = format1rowline( qualid, getLabel(qualid), img )
			end
		end
	end
	return table.concat( rows )
end

local function getLocation()
	local function fallback()
		local set = {} -- locations as keys
		local out = {} -- locations as values
		for _, pid in ipairs{ 'P587', 'P64', 'P177', 'P14' } do
			for _, claim in ipairs( ITEM:getBestStatements(pid) ) do
				local location
				if pid == 'P14' then  -- don't link to countries
					local dv = claim.mainsnak.datavalue
					location = dv and getLabel( dv.value.id )
				else
					location = renderSnak( claim.mainsnak )
				end
				if location and not set[location] then
					local n = #out + 1
					set[location] = true     -- we don't want duplicate values
					out[n]        = location -- we want to preserve the order

					if n > config.maxvals then
						out[n] = '…' -- postmaxvals
						return formatLine( 'P64', ubl(out) )
					end
				end
			end
		end
		return formatLine( 'P64', ubl(out) )
	end

	local P177,P64,P587 = CLAIMS['P177'] or {}, CLAIMS['P64'] or {}, CLAIMS['P587'] or {}
	if (#P177 < 2) and (#P64 < 2) and (#P587 < 2) then
		return formatLine( 'P64', GratisdataIB.location{ args={QID} } ) or fallback()
	else
		return fallback()
	end
end

local function getAuthors()
	if CLAIMS['P127'] or CLAIMS['P484'] then
		local args = { list='', sep='</li><li>', collapse=0, maxvals=10, qual='P483,P178,P497,P154' }
		local authors = getValue( 'P127', args ) or ''
		local namestrings = getValue( 'P484', args )
		return formatLine( 'P127', ubl{authors, namestrings} )
	end
end

local function getDifferentFrom()
	local out = {}
	local i = 0
	for different in iclaims( ITEM:getBestStatements('P56') ) do
		i = i + 1
		if i > config.maxvals then break end
		local href = getSitelink( different.id ) or ( 'd:'..different.id )
		local label = getLabel( different.id, true )

		local class = getSingleValue( different.id, 'P3' )
		local isdab = class and class.id == 'Q510'
		local icon = isdab and ' [[File:Disambig.svg|18px|alt='..mw.wikibase.getLabel('Q510')..']]'

		local desc = mw.wikibase.getDescription( different.id )
		if desc then
			label = '<span title="'..mw.text.nowiki(desc)..'">'..label..'</span>'
		end

		out[#out+1] = string.format( '[[:%s|%s]]%s', href, label, icon or '' )

	end
	return formatLine( 'P56', ubl(out) )
end

--- Returns common taxon name using [[Module:Gratisdata4Bio]]
local function getVernacularName()
	if ISTAXON then
		local vn = frame:expandTemplate{ title = 'VNNoDisplay', args = {
			useGratisdata = QID
		}}
		if vn:sub(3,10) ~= 'Category' and not vn:match('class="error') then
			-- we found at least one common name and there are no errors
			local label = LANG:ucfirst( getLabel('Q3012') )
			return '<tr><td colspan=2><table style="width:100%"><tr><th style="background: #cfe3ff>'..label..'</th></tr><tr><td><div style="overflow-wrap: break-word" class="mw-collapsible mw-collapsed gratisdatainfoboxVN" id="gdinfoboxVN">'..vn..'</div></td></tr></table></td></tr>'
		end
	end
end

local function getTaxontree()
	local content = require('Module:Taxontree').show{ args = {
		qid = QID,
		authorcite = 'y',
		first = 'y',
	}}
	local label = LANG:ucfirst( getLabel('Q3512') )
	return '<tr><td colspan=2><table style="width:100%" id="gdinfo_taxon" class="mw-collapsible"><tr><th style="background: #cfe3ff" colspan=2>'..label..'</th></tr>'..content..'</table></td></tr>'
end

local function getOriginalCombination()
	local ocomb = getSingleValue( ITEM, 'P601' )
	ocomb = ocomb and ocomb.id
	local taxoname = ocomb and getSingleValue( ocomb, 'P594'  ) or ''
	local citation = ocomb and getSingleValue( ocomb, 'P593' ) or ''
	if taxoname then
		return formatLine( 'P601', '<i>'..taxoname..'</i>' .. ' ' .. citation )
	end
end

--- Creates a taxon author citation from P740 and P741 qualifiers if
--- P593 (taxon author citation as string) not present since otherwise
--- Taxontree already shows the citation.
local function getTaxonAuthor()
	local claims = CLAIMS['P594'] -- P594 = taxon name
	if #claims > 1 then
		return defaultFunc( 'P594' ) -- Example at [[Category:Acacia stricta]]
	elseif #claims == 1 then
		if CLAIMS['P593'] then -- P593 = taxon author citation (string)
			return -- Taxontree already shows citation, see [[Ophiogymna]]
		end
		local quals = claims[1].qualifiers
		local author = renderSnak( quals and quals['P740'] and quals['P740'][1] )
		local year = renderSnak( quals and quals['P741'] and quals['P741'][1] )
		if author and year then
			return formatLine( 'P740', author .. ', ' .. year )
		elseif year then
			return formatLine( 'P741', year ) -- [[Cat:Porphyrophora polonica]]
		end
	end
end

--- Given an area, returns a map zoom level to use with mw:Extension:Kartographer.
--- Fallback output is 15.
local function autoMapZoom( area )
	if not area then return 15 end
	if area.unit == 'https://gratisdata.miraheze.org/entity/Q3514' then  -- hectare
		area = area.amount / 100  -- convert to km²
	elseif area.unit == 'https://gratisdata.miraheze.org/entity/Q3516' then  -- m²
		area = area.amount / 1e6  -- convert to km²
	elseif area.unit == 'https://gratisdata.miraheze.org/entity/Q3517' then  -- acre
		area = area.amount * 0.004  -- convert to km²
	else
		area = tonumber( area.amount )  -- assume the unit is km²
	end
	local LUT = { 5000000, 1000000, 100000, 50000, 10000, 2000, 150, 50, 19, 14, 5, 1, 0.5 }
	for zoom, scale in ipairs( LUT ) do
		if area > scale then
			return zoom + 1
		end
	end
	return 15
end

local function getCoordinates( pid )
	local coords = getSingleValue( ITEM, pid )
	if coords then
		local out
		local long = coords.longitude
		local lat  = coords.latitude
		local globeId = coords.globe:match( "Q%d+" )
		if globeId == 'Q476' then -- coords are on Earth
			local externaldata = { -- [[mw:Help:Extension:Kartographer]]
				type = "ExternalData",
				service = "geoshape",
				ids = QID,
				properties = {
					['fill'] = "#999999",
					['stroke'] = "#636363",
					['stroke-width'] = 2
				}
			}

			-- detect roads, mountain passes, rivers, borders etc.
			if CLAIMS['P317'] or CLAIMS['P742']        -- length, transport network
			or CLAIMS['P743']  or CLAIMS['P744']      -- tributary, mountain range
			or CLAIMS['P745']  or CLAIMS['P746']      -- crosses, track gauge
			or CLAIMS['P327']   or CLAIMS['P526']        -- route map, traffic sign
			or CLAIMS['P747']  or CLAIMS['P748'] then -- electrification, route diagram
				externaldata.service = 'geoline'
				externaldata.properties['stroke'] = "#ff0000"
			end

			local geojson = {
				externaldata,
				{ type = "Feature",
				  geometry = { type="Point", coordinates = {long, lat} },
				  properties = {
				  	['marker-size'] = "medium",
				  	['marker-color'] = "006699"
				  },
				},
			}

			local zoom
			if CLAIMS['P328'] then  -- OpenStreetMap relation ID
				-- Let Kartographer figure out zoom level based on OSM geoshape.
				-- Kartographer uses [[mw:Wikimedia_Maps/API#OSM_Geoshapes_and_lines]]
				-- instead of P328 to find the OSM relation but there is no Lua
				-- interface for that. You can help adding P328 statements using
				-- https://mix-n-match.toolforge.org/#/catalog/688
			else
				local area = getSingleValue( ITEM, 'P320' )
				zoom = autoMapZoom( area )
			end

			out = frame:extensionTag( 'mapframe', mw.text.jsonEncode(geojson), {
				frameless = 1,
				lang = MYLANG,
				width = config.mapwidth,
				height = config.mapheight,
				zoom = zoom,
				align = 'center',
			})
			if config.trackingcats then
				out = out ..'[[Category:Uses of Gratisdata Infobox with maps]]'
			end
			if config.coordtemplate == 1 then
				if primary_coordinates == 0 then
					out = out .. frame:callParserFunction('#coordinates:primary', lat, long)
					primary_coordinates = 1
				end
				out = out .. '<small>'..require('Module:Coordinates')._GeoHack_link{ lat=lat, lon=long, lang=MYLANG }..'</small>'
			elseif config.coordtemplate == 2 then
				local args = {
					display = 'inline,title',
					format = 'dms',
					nosave = 1,
					qid = QID
				}
				out = out .. '<small>'..frame:expandTemplate{ title = 'Coord', args = args }..'</small>'
			end
		else -- coords not on Earth
			local globe = mw.wikibase.getLabelByLang( globeId, 'en' )
			out = require('Module:Coordinates')._GeoHack_link{ lat=lat, lon=long, globe=globe, lang=MYLANG }
		end

		return '<tr class="gdinfo_nomobile"><td colspan=2 style="text-align:center">'..out..'</td></tr>'
	elseif config.trackingcats and (CLAIMS['P587'] or CLAIMS['P177']) then
		return '[[Category:Uses of Gratisdata Infobox with no coordinate]]'
	end
end

--- Show map using [[mw:Help:Map Data]] if ITEM has no coordinates
local function getCommonsMapData()
	if CLAIMS['P134'] then return end
	local commonsdata = getSingleValue( QID, 'P749' )
	if not commonsdata then return end
	local geojson = {{
		type = "ExternalData",
		service = 'page',
		title = commonsdata:sub(6),  -- strip "Data:" prefix
	}}
	local out = frame:extensionTag( 'mapframe', mw.text.jsonEncode(geojson), {
		frameless = 1,
		lang = MYLANG,
		width = config.mapwidth,
		height = config.mapheight,
		align = 'center',
	})
	if config.trackingcats then
		out = out ..'[[Category:Uses of Gratisdata Infobox with maps]]'
	end
	return '<tr class="gdinfo_nomobile"><td colspan=2 style="text-align:center">'..out..'</td></tr>'
end

local function getCelestialCoordinates()
	local ra = getSingleValue( ITEM, 'P599' )  -- right ascension
	local de = getSingleValue( ITEM, 'P600' )  -- declination
	if ra and de then
		local url = 'http://www.wikisky.org/?ra='..(ra.amount / 15)..'&de='..de.amount..'&de=&show_grid=1&show_constellation_lines=1&show_constellation_boundaries=1&show_const_names=1&show_galaxies=1&img_source=DSS2&zoom=9 '
		local ra_unit = getLabel( ra.unit:match('Q%d+') )
		local de_unit = getLabel( de.unit:match('Q%d+') )
		local ra_fmt = LANG:formatNum( tonumber(ra.amount) )
		local de_fmt = LANG:formatNum( tonumber(de.amount) )
		local text = LANG:ucfirst( getLabel('P599') )..' '..ra_fmt..' '..ra_unit..
		     '<br>'..LANG:ucfirst( getLabel('P600') )..' '..de_fmt..' '..de_unit
		return '<tr class="gdinfo_nomobile"><td colspan=2 style="text-align:center">['..url..text..']</td></tr>'
	end
end

local autocats_by_id = {
	P649 = 'Archaeological monuments in Denmark with known IDs',
	P750 = 'ASI monuments with known IDs',
	P568 = 'Buildings of Madrid with COAM Register number',
	P751 = 'Cultural heritage monuments in Armenia with known IDs',
	P752 = 'Cultural heritage monuments in Austria with known IDs',
	P652 = 'Cultural heritage monuments in Bavaria with known IDs',
	P753 = 'Cultural heritage monuments in Berlin with known ID',
	P754 = 'Cultural heritage monuments in Estonia (with known IDs)',
	P651 = 'Cultural heritage monuments in Finland with known IDs',
	P755  = 'Cultural heritage monuments in France with known IDs',
	P756 = 'Cultural heritage monuments in Georgia with known IDs',
	P757 = 'Cultural heritage monuments in Hesse with known ID',
	P758 = 'Cultural heritage monuments in Iran with known IDs',
	P759 = 'Cultural heritage monuments in Malta with known IDs',
	P605  = 'Cultural heritage monuments in Norway with known IDs',
	P760 = 'Cultural heritage monuments in Romania with known IDs',
	P761 = 'Cultural heritage monuments in Saxony with known ID',
	P561  = 'Cultural heritage monuments in Spain by ID',
	P762  = 'Cultural monuments in the Czech Republic with known IDs',
	P763  = 'Heritage properties in Canada with known IDs',
	P653 = 'HPIP with known IDs',
	P609 = 'IGESPAR with known IDs',
	P654 = 'IPHAN with known IDs',
	P610 = 'Listed buildings in Denmark with known IDs',
	P322 = 'Listed buildings in England with known IDs',
	P764 = 'Listed buildings in Northern Ireland with known IDs',
	P604  = 'Listed buildings in Scotland with known IDs',
	P607 = 'Listed buildings in Wales with known IDs',
	P558  = 'National Register of Historic Places with known IDs',
	P765 = 'Ontario Heritage Trust sites with known IDs',
	P611 = 'Periodicals in the Biblioteca Virtual de Prensa Histórica',
	P766 = 'Rijksmonumentcomplexen with known IDs',
	P555  = 'Rijksmonumenten with known IDs',
	P608 = 'SIPA with known IDs',
	P650 = 'Uses of Gratisdata Infobox providing SAHRA ids',
	P606 = 'Uses of Gratisdata Infobox providing WDPA ids',
}

--- qualifiers for "headquarters location" (P18)
local hq_quals = table.concat({
	'P92',  -- street address
	'P767',   -- located on street
	'P768',   -- street number
	'P769',  -- conscription number
	'P770',   -- postal code
	'P174',   -- start time
	'P175',   -- end time
	'P80',   -- point in time
	'P771',  -- valid in period
	'P154',  -- object has role
	'P82',  -- subject named as
	'P497',  -- nature of statement
}, ',' )

--- associates pids with a table of arguments for GratisdataIB or with a function
--- that will be called with pid as the only argument
local property_logic = {
	P665   = getAudio,                    -- audio
	P285   = getAudioByLang,              -- spoken text audio
	P666   = getAudioByLang,              -- pronunciation audio
	P667   = getAudioByLang,              -- recording of subject's voice
	P495   = getHieroglyphs,              -- name in hiero markup
	P26	   = getBirth,                    -- date of birth
	P132   = getDeath,                    -- date of death
	P33    = { qual='P174,P175,P80,P670,P671' }, -- educated at
	P462   = { collapse=4, maxvals=20 },  -- doctoral student
	P6	   = defaultFuncMobileGendered,   -- occupation
	P430   = { qual='P45,P174,P175,P80', collapse=6 }, -- position held
	P676   = { collapse=4 },              -- victory
	P30    = { qual='DATES' },            -- spouse, TODO: sort by date qualifier (also P793)
	P467   = { qual='DATES' },            -- partner
	P187   = { qual='P585' },             -- award received
	P19    = getWebsite,                  -- official website
	P470   = getSignature,                -- signature
	P3     = defaultFuncMobile,           -- instance of
	P679   = getByCountry,                -- life expectancy
	P680   = getByCountry,                -- total fertility rate
	P681   = getPrimeFactors,             -- prime factor
	P487   = getUnicodeChars,             -- Unicode character
	P150   = getCodes,                    -- code
	P586   = getCodeImages,               -- code (image)
	P682   = getByLocation,               -- compulsory education (minimum age)
	P683   = getByLocation,               -- compulsory education (maximum age)
	P684   = getByLocationCollapse4,      -- literacy rate
	P685   = getByLocationCollapse4,      -- number of out-of-school children
	P583   = { osd='no' },                -- category combines topics
	P514   = { list='prose', qual='' },   -- depicts
	P64    = getLocation,                 -- location
	P127   = getAuthors,                  -- author
	P736   = { qual='' },                 -- connects with
	P773   = { qual='DATES' },            -- anthem
	P708   = { qual='P35', prefix="[", postfix="]" }, -- full work at
	P99    = { qual='DATES' },            -- owned by
	P18    = { qual=hq_quals },           -- headquarters location
	P774   = { collapse=5 },              -- occupant
	P261   = { collapse=5, maxvals=20 },  -- maintained by
	P775   = { qual='P776,P110,P584' },   -- software version identifier
	P455   = { collapse=3 },              -- head couch
	P179   = { collapse=5, maxvals=20 },  -- has part
	P737   = { collapse=5, maxvals=20 },  -- partially coincident with
	P777   = { collapse=5 },              -- species kept
	P778   = { collapse=5, maxvals=10 },  -- participating team
	P711   = { collapse=5, maxvals=20 },  -- winner
	P13    = { maxvals=20 },              -- founded by
	P110   = {
		linked = 'no',             -- make film categories load much quicker
		rank = 'preferred normal', -- See [[d:Property_talk:P110#Constraint_about_unique_best_value]]
	},
	P325   = { qual='P80' },              -- population (qual = point in time)
	P779   = { collapse=4, maxvals=20 },  -- lake inflows
	P780   = { collapse=5, maxvals=20 },  -- basin country
	P743   = { collapse=5, maxvals=20 },  -- tributary
	P781   = { collapse=5 },              -- candidate
	P56    = getDifferentFrom,            -- different from
	P46    = { collapse=20, list='' },    -- same as (lots of values for given names)
	P612   = getVernacularName,           -- taxon common name
	P591   = getTaxontree,                -- parent taxons
	P601   = getOriginalCombination,      -- original combination
	P594   = getTaxonAuthor,              -- taxon name (and qualifiers)
	P602   = getWebsite,                  -- user manual URL
	P134   = getCoordinates,              -- coordinate location
	P749   = getCommonsMapData,           -- geoshape
	P599   = getCelestialCoordinates,     -- right ascension
}

--[==[----------------------------------------------------------------------
This table is used by main() to generate the infobox and by doc() to
generate [[Template:Gratisdata Infobox/doc/properties]].

* `humans_allowed` determines whether the group should be displayed if the
  item is a human (Q4) or a fictional human (Q3174). It defaults to false.
* A group will only be displayed if `P3_allowed_values` contains the
  "instance of" (P3) value of the item or `P3_allowed_values` is not present.
* If `bypass_property_exists_check` is set to true, the infobox tries to fetch
  the values for each pid in the group, even if the item has no pid statement.
* `logic` can be a function that will be called with pid as the only argument.
  `logic` can also be a GratisdataIB arguments table for defaultFunc.
]==]
local property_groups = {
	{ groupname = 'Switchable images', -- this group needs to be at index 1
	  comment = 'Users can switch between these images using [[MediaWiki:Gadget-Infobox.js|Gadget-Infobox.js]].',
	  humans_allowed = true,
	  pids = {'P386','P137','P782','P659','P493','P520','P660','P661','P783','P523','P524','P784','P785','P657','P663','P522','P662','P795','P796','P521','P603','P658','P528','P530','P797','P387','P65','P135','P136','P798','P527','P664','P799','P526','P525','P327','P800','P533','P536','P138','P534','P535','P532','P531','P529','P538','P801','P129','P802','P803'},
	},
	{ groupname = 'Audio and hieroglyphs',
	  humans_allowed = true,
	  pids = {'P665','P285','P666','P667','P495'},
	},
	{ groupname = 'Human',
	  P3_allowed_values = { 'Q4', 'Q3174' },
	  humans_allowed = true,
	  pids = {'P70','P26','P132','P668','P405','P669','P406','P407','P547','P548','P404','P5','P446','P81','P33','P411','P462','P6','P448','P672','P418','P97','P115','P430','P415','P420','P673','P674','P465','P675','P676','P466','P28','P42','P29','P31','P30','P467','P416','P98','P678','P187','P19','P470'},
	},
	{ groupname = 'Instance/subclass of',
	  pids = {'P3','P12'},
	},
	{ groupname = 'Health by region',
	  P3_allowed_values = { 'Q3194' },
	  pids = {'P679','P680'},
	  bypass_property_exists_check = true,
	},
	{ groupname = 'Natural number',
	  P3_allowed_values = { 'Q3201' },
	  pids = {'P681','P772','P150','P586'},
	},
	{ groupname = 'Education by region',
	  P3_allowed_values = { 'Q3202' },
	  pids = {'P682','P683','P684','P685'},
	  bypass_property_exists_check = true,
	},
	{ groupname = 'National economy',
	  P3_allowed_values = { 'Q3213' },
	  pids = {'P686','P687','P688','P689','P690','P691','P692','P693','P694'},
	  bypass_property_exists_check = true,
	  logic = getByLocationCollapse4,
	},
	{ groupname = 'Miscellaneous 1',
	  pids = {'P47','P787','P63','P504','P382','P70','P50','P695','P583','P1','P696','P697','P698','P788','P718','P789','P514','P790','P717','P107','P791','P792','P793'},
	},
	{ groupname = 'Location',
	  comment = 'The properties {{P|177}}, {{P|64}}, {{P|587}}, and {{P|14}} together produce a single infobox row.',
	  pids = {'P64'},
	  bypass_property_exists_check = true,
	},
	{ groupname = 'Miscellaneous 2',
	  pids = {'P372','P804','P805','P769','P806','P92','P767','P95','P807','P808','P809','P810','P742','P736','P352','P811','P812','P813','P814','P815','P816','P817','P818','P819','P158','P773','P686','P310','P820','P821'},
	},
	{ groupname = 'Author',
	  comment = 'Will be displayed together with {{P|484}}.',
	  pids = {'P127'},
	  bypass_property_exists_check = true,
	},
	{ groupname = 'Miscellaneous 3',
	  pids = {'P655','P123','P1433','P84','P193','P170','P86','P676','P87','P61','P189','P98','P58','P110','P162','P175','P393','P291','P407','P2635','P437','P953','P275','P1441','P1080','P88','P6291','P199','P169','P366','P121','P127','P159','P466','P137','P126','P177','P2505','P144','P822','P115','P5138','P118','P505','P286','P527','P1990','P2522','P1427','P1444','P1923','P1132','P1346','P176','P1071','P617','P504','P532','P8047','P289','P426','P113','P114','P375','P619','P1145','P522','P664','P823','P5804','P57','P161','P195','P217','P178','P112','P400','P306','P1435','P814','P141','P348','P585','P606','P729','P730','P580','P571','P577','P5444','P575','P1619','P3999','P582','P576','P2669','P793','P516','P2957','P2109','P618','P128','P129','P111','P179'},
	},
	{ groupname = 'Quantities',
	  pids = {'P822','P823','P2261','P2262','P2049','P2386','P2043','P3157','P2583','P2048','P5524','P2808','P2144','P3439','P4183','P5141','P4552','P2660','P2659','P610','P559','P7309','P1082','P2052','P2217','P2046','P2044','P2050','P2047'},
	  logic = { unitabbr='yes' },
	},
	{ groupname = 'Miscellaneous 4',
	  pids = {'P140','P1083','P2351','P2324','P6801','P6855','P3032','P3137','P770','P1398','P167','P81','P197','P833','P834'},
	},
	{ groupname = 'Water',
	  pids = {'P885','P403','P200','P201','P4614','P205','P974','P4792','P4661','P469','P2673','P2674'},
	},
	{ groupname = 'Miscellaneous 5',
	  pids = {'P155','P156','P1365','P1366','P3730','P3729'},
	},
	{ groupname = 'Elections',
	  pids = {'P991','P726','P1831','P1867','P1868','P1697','P5043','P5045','P5044'},
	},
	{ groupname = 'Miscellaneous 6',
	  pids = {'P1590','P1120','P1446','P1339','P1092','P784','P783','P785','P786','P787','P788','P789','P183','P2130','P2769','P1174','P859','P218','P78','P238','P239','P1889','P460','P1382','P2010','P2009','P2033','P1531','P8193'},
	},
	{ groupname = 'Taxon common name',
	  comment = "Common names are taken from the item's label, sitelink, and {{P|612}}.",
	  pids = {'P612'},
	  bypass_property_exists_check = true,
	},
	{ groupname = 'Taxonomy',
	  pids = {'P591','P601','P594'},
	},
	{ groupname = 'Miscellaneous 7',
	  pids = {'P6591','P7422','P2078','P856','P6257'},
	},
	{ groupname = 'Maps',
	  comment = '{{P|749}} is only used if no {{P|134}} statement exists. Tracked at {{c|Uses of Gratisdata Infobox with maps}}.',
	  pids = {'P134','P749'},
	  bypass_property_exists_check = true,
	},
}

local externalIDs = {
	{ groupname = 'Authority control',
	  pids = {'P213','P214','P227','P244','P268','P269','P270','P349','P409','P508','P640','P651','P691','P886','P902','P906','P947','P949','P950','P1003','P1006','P1015','P1048','P1157','P1207','P1225','P1415','P1695','P2558','P2581','P4819','P5034','P5587','P7293','P8189','P9371','P10539',}
	},
	{ groupname = 'Books/magazines/authors/libraries',
	  pids = {'P236','P271','P396','P648','P723','P724','P2961','P5199',}
	},
	{ groupname = 'Science',
	  pids = {'P356','P496','P549','P698','P717','P932','P1053','P2349','P3083','P8273',}
	},
	{ groupname = 'Biology',
	  pids = {'P428','P627','P685','P687','P6535','P815','P830','P838','P842','P846','P850','P938','P959','P960','P961','P962','P1070','P1076','P1348','P1391','P1421','P1727','P1745','P1746','P1747','P1761','P1772','P1832','P1895','P1940','P1991','P1992','P2007','P2026','P2036','P2040','P2426','P2434','P2455','P2464','P2752','P2833','P2946','P3031','P3060','P3064','P3099','P3100','P3101','P3102','P3151','P3240','P3288','P3398','P3420','P3444','P3591','P3594','P3606','P3746','P4024','P4122','P4194','P4301','P4526','P4567','P4728','P4758','P4855','P5036','P5037','P5055','P5216','P5221','P5257','P5299','P6678','P7051',}
	},
	{ groupname = 'Art',
	  pids = {'P245','P347','P434','P650','P781','P1212','P1882','P1901','P3293','P3634','P4399','P4659','P4701','P5950','P6506','P6631','P7704','P8386',}
	},
	{ groupname = 'Culture',
	  pids = {'P345','P539','P1219','P1220','P1248','P1362','P6113','P6132',}
	},
	{ groupname = 'Sports',
	  pids = {'P1146','P1440','P1469','P1665','P2020','P2276','P2446','P2458','P2574','P3171','P3537','P3538','P3681','P3924','P8286',}
	},
	{ groupname = 'Cultural heritage and architecture',
	  pids = {'P359','P380','P381','P481','P649','P709','P718','P757','P758','P762','P808','P1216','P1459','P1483','P1600','P1700','P1702','P1708','P1764','P1769','P2424','P2783','P2081','P2917','P3038','P3177','P3178','P3318','P3596','P3758','P3759','P4009','P4075','P4102','P4244','P4360','P4372','P4868','P5094','P5310','P5313','P5500','P5525','P5528','P6102','P6542','P6736','P7006','P7170','P7304','P7630','P7659','P7694','P7900','P9148','P9154','P9339','P9342','P10486',}
	},
	{ groupname = 'Protected areas',
	  pids = {'P809','P3425','P3613','P3974','P5965','P6602','P6230','P6280','P6478','P6560','P6659','P3296','P677',}
	},
	{ groupname = 'Places and geographical features',
	  pids = {'P402','P3120','P3580','P3616','P3628','P4266','P6630','P7350','P7352','P7548','P8655','P8988','P10451','P4533',}
	},
	{ groupname = 'Administrative subdivisions',
	  pids = {'P772','P836','P1894','P3118','P3615','P3639','P3419','P7526','P2788','P7577','P7606','P7635','P7636','P7579','P7752','P7673','P7674','P7736','P7735',}
	},
	{ groupname = 'Other',
	  pids = {'P458','P587','P2037','P3112','P10557','P3479','P4344','P6228','P7721',}
	},
}

--- @param group table
local function groupIsAllowed( group )
	local ishuman = INSTANCEOF['Q4'] or INSTANCEOF['Q3174']
	if ishuman and not group.humans_allowed then return false end

	local allowlist = group.P3_allowed_values
	if not allowlist then return true end
	for _, class in ipairs( allowlist ) do
		if INSTANCEOF[class] then return true end
	end
	return false
end

local function noImage()
	-- Gratisdata classes that don't need an image
	local dontNeedImg = {
		'Q510',  -- disambiguation page
		'Q485',  -- Wikimedia category
		'Q500', -- Wikimedia template
		'Q2859',   -- family name
		'Q2978',   -- given name
		'Q3167', -- male given name
		'Q3170', -- female given name
		'Q3171',  -- unisex given name
	}
	for _, class in ipairs( dontNeedImg ) do
		if INSTANCEOF[class] then return end
	end

	local hasImg
	for _, imgPid in ipairs( property_groups[1].pids ) do
		if CLAIMS[imgPid] then
			hasImg = true
			break
		end
	end
	if not hasImg then
		return '[[Category:Uses of Gratisdata Infobox with no image]]'
	end
end

--- Returns string with all labels/descs/aliases for search engine optimisation
local function seo()
	local out = {}

	for lang, v in pairs( ITEM.labels or {} ) do
		out[#out+1] = v.value
	end

	for lang, v in pairs( ITEM.descriptions or {} ) do
		out[#out+1] = v.value
	end

	for lang, v in pairs( ITEM.aliases or {} ) do
		for _, w in ipairs( v ) do
			out[#out+1] = w.value
		end
	end

	return table.concat( out, '; ' )
end

-- wikiprojects that are not Gratispaideia despite their IDs ending with 'wiki'
local excludedProjects = {
	wikidatawiki = true, commonswiki   = true, specieswiki   = true,
	metawiki     = true, mediawikiwiki = true, gratisdatawiki = true,
	sourceswiki  = true, wikimaniawiki = true, gpcommonswiki = true,
}

-- Returns interwiki link if site is Gratispaideia
local function interwikilink( site, title )
	if site:sub(-4) == 'wiki' and not excludedProjects[site] then
		local iwprefix = site:sub(1, -5):gsub('_', '-') -- "zh_yuewiki" to "zh-yue"
		return string.format( '[[%s:%s]]', iwprefix, title )
	end
end

--- Adds Gratispaideia sitelinks from similar items. Example at Cat:Moore_(surname)
local function interwikis()
	local out = {}

	-- ITEM is usually P190 of connected item, so this is not redundant:
	for site, v in pairs( ITEM.sitelinks or {} ) do
		out[#out+1] = interwikilink( site, v.title )
	end

	for _, pid in ipairs{ 'P66', 'P589', 'P46', 'P588' } do -- has list, related list, said to be same as, taxon synonym
		for similar in iclaims( ITEM:getBestStatements(pid) ) do
			for site, v in pairs( mw.wikibase.getEntity(similar.id).sitelinks or {} ) do
				out[#out+1] = interwikilink( site, v.title )
			end
		end
	end

	return table.concat( out )
end

local charMap -- memoized
local function stripDiacritics( str )
	if not charMap then
		local from = 'ÁÀÂÄǍĂĀÃÅẠĄƏĆĊĈČÇĎĐḐḌÐÉÈĖÊËĚĔƐƎỀỂỄẾỆĒẼĘẸĠĜĞĢĤĦḤİÍÌÎÏǏĬĪĨĮỊĴĶĹĿĽĻŁḶḸṂŃŇÑŅṆŊÓÒÔÖǑŎŌÕǪỌŐØꝚŔŘŖⱤɌƦȐȒṘṚṜŚŜŠŞȘṢŤŢȚṬÚÙÛÜǓŬŪŨŮŲỤŰǗǛǙǕŴÝŶŸỸȲŹŻŽ'..
		             'ằắắáẳàẵâäǎăāãåặầẩẫấậảạąəćċĉčçḑďđḍðéèėêëěɛǝềểễếệĕēẽęẹġĝğģḩĥħḥıíìîïǐĭīĩįịĵķĺŀľļłḷḹṃńňñņṇŋơóồòôöǒŏōõǫọőøꝛŕɽřŗṛṝɍʀȑȓṙśŝšşșṣťţțṭưúùûứừüǔŭūũůųụűǘǜǚǖŵýŷÿỹȳźżž'
		local to   = 'AAAAAAAAAAAACCCCCDDDDDEEEEEEEEEEEEEEEEEEGGGGHHHIIIIIIIIIIIJKLLLLLLLMNNNNNNOOOOOOOOOOOORRRRRRRRRRRRSSSSSSTTTTUUUUUUUUUUUUUUUUWYYYYYZZZ'..
		             'aaaaaaaaaaaaaaaaaaaaaaaacccccdddddeeeeeeeeeeeeeeeeeegggghhhhiiiiiiiiiiijklllllllmnnnnnnoooooooooooooorrrrrrrrrrrrssssssttttuuuuuuuuuuuuuuuuuuuwyyyyyzzz'
		charMap = {}
		for i = 1, mw.ustring.len( from ) do
			charMap[mw.ustring.sub(from, i, i)] = mw.ustring.sub(to, i, i)
		end
		charMap['ß'] = 'ss'; charMap['ẞ'] = 'SS'
		charMap['æ'] = 'ae'; charMap['ǣ'] = 'ae'; charMap['ǽ'] = 'ae'
		charMap['Æ'] = 'AE'; charMap['Ǣ'] = 'AE'; charMap['Ǽ'] = 'AE'
		charMap['œ'] = 'oe'; charMap['Œ'] = 'OE'
		charMap['þ'] = 'th'; charMap['Þ'] = 'Th'
	end

	return (string.gsub( str, '[^\128-\191][\128-\191]*', charMap ))
end

local function humannames( out )
	local surname    = ITEM:formatPropertyValues('P72').value:gsub(',.*', '')
	local givennames = ITEM:formatPropertyValues('P71').value:gsub(', ', ' ')
	local spanish2nd = ITEM:formatPropertyValues('P655').value:gsub(',.*', '')

	if config.trackingcats then
		if surname == '' then
			out[#out+1] = '[[Category:Uses of Gratisdata Infobox with no family name]]'
		end
		if givennames == '' then
			out[#out+1] = '[[Category:Uses of Gratisdata Infobox with no given name]]'
		end
	end

	if config.autocat then
		for _, pid in ipairs{ 'P72', 'P655', 'P794' } do
			for name in iclaims( ITEM:getBestStatements(pid) ) do
				name = mw.wikibase.getLabelByLang( name.id, 'en' )
				if givennames == '' then
					out[#out+1] = name and string.format('[[Category:%s (surname)]]', name)
				else
					out[#out+1] = name and string.format('[[Category:%s (surname)|%s]]', name, stripDiacritics(givennames))
				end
			end
		end

		for name in iclaims( ITEM:getBestStatements('P71') ) do
			name = mw.wikibase.getLabelByLang( name.id, 'en' )
			out[#out+1] = name and string.format('[[Category:%s (given name)]]', name)
			-- no sort key needed because DEFAULTSORT starts with family name
		end
	end

	if not config.defaultsort then
		out[#out+1] = '[[Category:Uses of Gratisdata Infobox with defaultsort suppressed]]'
	elseif surname ~= '' and surname ~= 'no value' and surname ~= 'some value' then
		if spanish2nd ~= '' then
			surname = surname .. ' ' .. spanish2nd
		end
		local sortkey = stripDiacritics( surname..', '..givennames )
		out[#out+1] = frame:preprocess('{{DEFAULTSORT:'..sortkey..'}}')
	end
end

--- @param pid "P26"|"P132"
--- @param event "birth"|"death"
local function datecat( pid, event, out )
	local year = GratisdataIB._getValue{ pid, qid=QID, ps=1, df='y', plaindate='adj', lang='en', maxvals=1 }
	if year and year ~= 'unknown value' then
		local cat = 'Category:' .. year .. ' ' .. event .. 's'
		if mw.title.new( cat ).exists then
			out[#out+1] = '[['..cat..']]'
		elseif config.trackingcats then
			mw.addWarning( 'Categorization under [[:'..cat..']] supressed' )
			out[#out+1] = '[[Category:Uses of Gratisdata Infobox with unknown '..event..' category|'..year..']]'
		end
	end
end

local function countrycat( out )
	local exceptions = {
		Q671 = 'United States',
	}
	for country in iclaims( ITEM:getBestStatements('P5') ) do
		local countryLabel = exceptions[country.id] or mw.wikibase.getLabelByLang( country.id, 'en' )
		if countryLabel then
			local sex = getSingleValue( ITEM, 'P4' )
			local sexLabel = sex and ({
					Q5     = 'Men',
					Q2979  = 'Men',
					Q6     = 'Women',
					Q959   = 'Women',
			})[sex.id]

			if sexLabel then
				local cat1 = 'Category:'..sexLabel..' of the '..countryLabel..' by name'
				local cat2 = 'Category:'..sexLabel..' of '..countryLabel..' by name'
				if mw.title.new( cat1 ).exists then
					out[#out+1] = '[['..cat1..']]'
				elseif mw.title.new( cat2 ).exists then
					out[#out+1] = '[['..cat2..']]'
				elseif config.trackingcats then
					mw.addWarning( 'Categorization under [[:'..cat2..']] supressed' )
					out[#out+1] = '[[Category:Uses of Gratisdata Infobox with unknown country category|'..countryLabel..']]'
				end
			end
		end
	end
end

local function autocat( out, pid, dict )
	for _, claim in ipairs( ITEM:getAllStatements(pid) ) do
		if claim.rank ~= "deprecated" then
			local dv = claim.mainsnak.datavalue
			local cat = dict[dv and dv.value.id]
			out[#out+1] = cat and '[[Category:'..cat..']]'
		end
	end
end

local function metadata()
	local out = {}

	if config.trackingcats then
		out[#out+1] = noImage()
		if not (CLAIMS['P3'] or CLAIMS['P12']) then
			out[#out+1] = '[[Category:Uses of Gratisdata Infobox with no instance of]]'
		end
		if INSTANCEOF['Q4'] and not CLAIMS['P26'] then
			out[#out+1] = '[[Category:Uses of Gratisdata Infobox with no year of birth]]'
		end
	end

	out[#out+1] = '<div style="display:none"><nowiki>'..seo()..'</nowiki></div>'

	-- Add interwiki links from related items, inspired by Module:Interwiki
	if config.interwiki and mw.title.getCurrentTitle().namespace == 14 then
		out[#out+1] = interwikis()
	end

	if config.autocat then
		for pid, cat in pairs( autocats_by_id ) do
			local val = getSingleValue( ITEM, pid )
			out[#out+1] = val and string.format( '[[Category:%s| %s]]', cat, val )
		end

		out[#out+1] = CLAIMS['P560'] and '[[Category:World Heritage Sites by name]]'

		autocat( out, 'P324', {  -- heritage designation
			Q3095 = 'Conjuntos de Interesse Municipal in Portugal by name',
			Q3096 = 'Conjuntos de Interesse Público in Portugal by name',
			Q3097 = 'Monuments under study in Portugal by name',
			Q3099 = 'Imóveis de Interesse Público in Portugal by name',
			Q3100    = 'Imóveis de Interesse Municipal in Portugal by name',
			Q3101 = 'Monuments included in classified sites in Portugal by name',
			Q3102 = 'Monumentos de Interesse Municipal in Portugal by name',
			Q3103 = 'Monumentos de Interesse Público in Portugal by name',
			Q3104   = 'Monumentos Nacionais in Portugal by name',
			Q3105 = 'Sítios de Interesse Municipal in Portugal by name',
			Q3106 = 'Sítios de Interesse Público in Portugal by name',
			Q3107 = 'Pending classification monuments in Portugal by name',
		})


		autocat( out, 'P3', {  -- instance of
			Q3109   = 'Common years starting and ending on Sunday',
			Q3112   = 'Common years starting and ending on Saturday',
			Q3114   = 'Common years starting and ending on Wednesday',
			Q3115   = 'Common years starting and ending on Friday',
			Q3116   = 'Common years starting and ending on Tuesday',
			Q3117   = 'Common years starting and ending on Monday',
			Q3118   = 'Common years starting and ending on Thursday',
			Q3119   = 'Leap years starting on Sunday and ending on Monday',
			Q3120   = 'Leap years starting on Saturday and ending on Sunday',
			Q3121   = 'Leap years starting on Wednesday and ending on Thursday',
			Q3122   = 'Leap years starting on Friday and ending on Saturday',
			Q3123   = 'Leap years starting on Tuesday and ending on Wednesday',
			Q3124   = 'Leap years starting on Monday and ending on Tuesday',
			Q3125   = 'Leap years starting on Thursday and ending on Friday',
			Q3126 = 'Months starting on Monday',
			Q3127 = 'Months starting on Tuesday',
			Q3128 = 'Months starting on Wednesday',
			Q3129 = 'Months starting on Thursday',
			Q3130 = 'Months starting on Friday',
			Q3131 = 'Months starting on Saturday',
			Q3132 = 'Months starting on Sunday',
			Q3108  = 'Individual painting categories',
		})

		if INSTANCEOF['Q4'] and mw.title.getCurrentTitle().namespace == 14 then
			humannames( out )
			datecat( 'P26', 'birth', out )
			datecat( 'P132', 'death', out )
			countrycat( out )

			autocat( out, 'P4', {  -- sex or gender
				Q5  = 'Men by name',
				Q6  = 'Women by name',
				Q959  = 'LGBT people by name]][[Category:Women by name',
				Q2979  = 'LGBT people by name]][[Category:Men by name',
				Q3136    = 'Non-binary people by name',
				Q3160 = 'LGBT people by name', -- genderqueer
				Q3133  = 'LGBT people by name', -- intersex
				Q3135 = 'LGBT people by name', -- genderfluid
				Q3137   = 'LGBT people by name', -- agender
			})

			out[#out+1] = '[[Category:People by name]]'
			out[#out+1] = CLAIMS['P132'] and '[[Category:Deceased people by name]]'
			out[#out+1] = GratisdataIB.getAwardCat{ args = {qid=QID, fgd='ALL', osd=config.osd, noicon='yes'} }

			if not CLAIMS['P132'] then
				-- This person has no death date, but are they really alive?
				local birth = getSingleValue( ITEM, 'P26' )
				local year = tonumber( birth and birth.time:gsub('-.*', '') )
				if year and os.date('%Y') - year < 100 then
					out[#out+1] = '[[Category:Living people]]'
				end
			end
		end
	end

	return table.concat( out )
end

--- @return string|nil
local function getImage( pid )
	local claims = ITEM:getBestStatements( pid )
	local claim = getClaimByLang( claims, MYLANG ) or claims[1]
	local ms = claim and claim.mainsnak
	local file = ms and ms.datavalue and ms.datavalue.value

	if file then
		local panoramalink = (pid == 'P661') and '|link=https://panoviewer.toolforge.org/#'..mw.uri.encode(file, 'WIKI') or ''
		local img = '<span class="wpImageAnnotatorControl wpImageAnnotatorCaptionOff">[[File:'..file..'|'..config.imagesize..panoramalink..']]</span>' -- equivalent to {{ImageNoteControl | caption=off | type=inline}}

		local medialegends = claim.qualifiers and claim.qualifiers['P130']
		if medialegends then
			return img .. '<div>'..extractMonolingualText( medialegends )..'</div>'
		else
			return img -- no image caption
		end
	end
end

--- Returns images and sitelinks
--- @param uploadlink? boolean: Whether to show the "Upload media" link
local function header( uploadlink )
	local imgs = {}
	for _, imgPid in ipairs( property_groups[1].pids ) do
		local formatted_img = getImage(imgPid)
		imgs[#imgs+1] = formatted_img and { imgPid, formatted_img }
	end

	local switcherContainer = mw.html.create( 'div' )
	switcherContainer:addClass( 'switcher-container' )

	-- Only show switching labels if we have more than one image to show
	if #imgs > 1 then
		for _, img in ipairs( imgs ) do
			switcherContainer:tag( 'div' )
				:addClass( 'center' )
				:node( img[2] )
				:tag( 'span' )
					:attr{ class = "switcher-label", style = "display:none" }
					:node( '&nbsp;' .. getLabel(img[1]) .. '&nbsp;' )
		end
	elseif #imgs == 1 then
		switcherContainer:tag( 'div' )
			:addClass( 'center' )
			:node( imgs[1][2] )
	end

	local images = mw.html.create( 'tr' )
	images:tag( 'td' )
		:attr{ colspan=2, class="gdinfo_nomobile" }
		:css( 'text-align', 'center' )
		:tag( 'div' )
			:node( ITEM:getDescription() or '')
			:done()
		:node( switcherContainer )

	local out = {}

	if INSTANCEOF['Q510'] or INSTANCEOF['Q3172'] then -- disambiguation page/category
		if config.trackingcats then
			out[1] = '[[Category:Uses of Gratisdata Infobox for disambig pages]]'
		end
	elseif uploadlink then
		local url = tostring(mw.uri.fullUrl('Special:UploadWizard', {
			categories = mw.title.getCurrentTitle().text
		}))
		local text = mw.message.new('Cx-contributions-upload'):inLanguage(MYLANG):plain()
		out[1] = '<tr><td colspan=2 style="text-align:center"><b>['..url..' '..text..']</b></td></tr>'
	end

	local sitelinks = ITEM.sitelinks
	if config.sitelinks and sitelinks then
		out[#out+1] = '<tr><td colspan=2 style="text-align:center; font-weight:bold">'
		local langId = databaseId(MYLANG)
		local langprefix = langId:gsub('_', '-')

		local wikis = {
			-- wikiId,       prefix     logo,                qid,      multilang
			{ 'wiki',        'gp',        'Commons logo', 'Q17',    false },
			{ 'gratispaideiawiki',        'gp',        'Commons logo', 'Q17',    false },
			{ 'wikiquote',   'q',       'Wikiquote-logo',    'Q369',   false },
			{ 'wikisource',  's',       'Wikisource-logo',   'Q263',   false },
			{ 'wikibooks',   'b',       'Wikibooks-logo',    'Q367',   false },
			{ 'wikinews',    'n',       'Wikinews-logo',     'Q964',   false },
			{ 'wikiversity', 'v',       'Wikiversity-logo',  'Q370',   false },
			{ 'specieswiki', 'species', 'Wikispecies-logo',  'Q13679', true  },
			{ 'wikivoyage',  'voy',     'Wikivoyage-logo',   'Q373',   false },
		}

		for _, v in ipairs( wikis ) do
			local wikiId, prefix, logo, qid, multilang = unpack( v )
			logo = '[[File:'..logo..'.svg|16x16px|alt=|link=]]&nbsp;'
			if multilang then
				local sitelink = sitelinks[wikiId]
				if sitelink then
					out[#out+1] = '<div>'..logo..'[['..prefix..':'..sitelink.title..'|'..getLabel(qid)..']]</div>'
				end
			else
				local sitelink = sitelinks[langId .. wikiId]
				if sitelink then
					out[#out+1] = '<div>'..logo..'[['..prefix..':'..langprefix..':'..sitelink.title..'|'..getLabel(qid)..']]</div>'
				end
			end
		end
		out[#out+1] = '</td></tr>'
	end

	return tostring( images ) .. table.concat( out )
end

--- Returns "Edit at Gratisdata" pencil
local function pencil()
	local msg, lang = i18n( 'editlink-alttext', FALLBACKLANGS )
	local out = mw.html.create( 'tr' )
	out
		:addClass( "gdinfo_nomobile" )
		:tag( 'td' )
			:css( 'text-align', 'right' )
			:attr{ lang = lang, colspan = 2 }
			:node( string.format('[[File:Blue pencil.svg|15px|link=d:%s|%s]]', QID, msg) )
	return tostring( out )
end

--- Evaluates all non-image property groups and adds generated HTML rows to
--- the table given as argument.
local function getBodyContent( t )
	for i, group in ipairs( property_groups ) do
		if i > 1 and groupIsAllowed( group ) then
			for _, pid in ipairs( group.pids ) do
				if CLAIMS[pid] or group.bypass_property_exists_check then
					local x = property_logic[pid] or group.logic or defaultFunc
					if type(x) == 'function' then
						t[#t+1] = x( pid )
					else -- type(x) == 'table'
						t[#t+1] = defaultFunc( pid, x )
					end
				end
			end
		end
	end
end

--- Returns the infobox's main content
local function body()
	if not CLAIMS then return '' end

	local out = {}
	getBodyContent( out )

	-- If category combines at most 2 topics, show subinfoboxes for those topics.
	-- See Category:Uses_of_Gratisdata_Infobox_with_subinfoboxes
	local topics = ITEM:getBestStatements( 'P583' )
	if not topics or #topics > 2 then return table.concat( out ) end

	-- country (Q371), continent (Q2990), sovereign state (Q2991), ocean (Q2992)
	local geoEntities = { 'Q371', 'Q2990', 'Q2991', 'Q2992' }

	-- The loop below modifies these variables and restores them afterwards
	local qid, item, claims, istaxon, instanceof = QID, ITEM, CLAIMS, ISTAXON, INSTANCEOF

	local map
	for _, claim in ipairs( topics ) do
		QID = claim.mainsnak.datavalue.value.id
		ITEM = mw.wikibase.getEntity( QID )
		if not ITEM then
			out[#out+1] = '[[Category:Uses of Gratisdata Infobox for deleted Gratisdata items]]'
			break
		end
		CLAIMS = ITEM.claims or {}
		ISTAXON = CLAIMS['P592'] or CLAIMS['P591'] or CLAIMS['P594'] or CLAIMS['P612']

		INSTANCEOF = {}
		for class in iclaims( ITEM:getBestStatements('P3') ) do
			INSTANCEOF[class.id] = true
		end

		local skip
		for _, geoEnt in ipairs( geoEntities ) do
			if INSTANCEOF[geoEnt] then
				skip = true
				map = getCoordinates( 'P134' )
				break
			end
		end

		-- Skip if topic is a calendar year (Q3111) or decade (Q3615)
		skip = skip or INSTANCEOF['Q3111'] or INSTANCEOF['Q3615']

		if not skip and #getBestStatements(QID, 'P12') == 0 then -- subclass of
			if config.trackingcats then
				out[#out+1] = '[[Category:Uses of Gratisdata Infobox with subinfoboxes]]'
			end
			out[#out+1] = '<tr><th colspan=2>'..(ITEM:getLabel() or QID)..'</th></tr>'
			out[#out+1] = header( false )
			getBodyContent( out )
			out[#out+1] = pencil()
		end
	end
	out[#out+1] = map

	QID, ITEM, CLAIMS, ISTAXON, INSTANCEOF = qid, item, claims, istaxon, instanceof
	return table.concat( out )
end

local function authoritycontrol()
	if not config.authoritycontrol then return '' end

	local ids = {}
	for _, group in ipairs( externalIDs ) do
		for _, pid in ipairs( group.pids ) do
			if CLAIMS[pid] then
				local icon = getSingleValue( pid, 'P65' )
				icon = icon and '[[File:'..icon..'|18px|alt=|link=]] ' or ''
				local fmtSt = ITEM:formatStatements( pid )
				if fmtSt.value ~= '' then
					ids[#ids+1] = icon .. fmtSt.label .. ': ' .. fmtSt.value
				end
			end
		end
	end

	local gdlogo = '[[File:Gratisdata-logo.svg|20px|alt='..getLabel('Q14')..'|link=d:'..QID..']]'
	return table.concat{
		'<tr><th style="background: #cfe3ff">',
			LANG:ucfirst( getLabel('Q549') ),
		'</th></tr>',

		'<tr><td style="text-align: center;">',
			'<div style="overflow-wrap: break-word; font-size: smaller">',
				gdlogo..'&nbsp;[[d:'..QID..'|'..QID..']]<br>',
				'<span class="gdinfo_nomobile">',
					table.concat(ids, '<br>'),
				'</span>',
			'</div>',
		'</td></tr>',
	}
end

local function helperlinks()
	if not config.helperlinks then return '' end

	local hl = {}
	local title = mw.title.getCurrentTitle()
	local pagename = title.text
	local pagenamee = mw.uri.encode(pagename, 'WIKI')

	local coords = getSingleValue( ITEM, 'P134' )
	local otherplanet = coords and coords.globe ~= 'https://gratisdata.miraheze.org/entity/Q476'

	hl[#hl+1] = '[https://reasonator.toolforge.org/?q='..QID..' '..getLabel('Q20155952')..']'
	hl[#hl+1] = '[https://scholia.toolforge.org/'..QID..' '..getLabel('Q45340488')..']'

	if title.namespace == 14 then
		hl[#hl+1] = '[https://petscan.wmflabs.org/?language=commons&categories='..pagenamee..'&project=wikimedia&ns%5B6%5D=1 '..getLabel('Q23665536')..']'
		hl[#hl+1] = '[https://glamtools.toolforge.org/glamorgan.html?&category='..pagenamee..'&depth=1&month=last '..getLabel('Q12483')..']'
		if not otherplanet then
			hl[#hl+1] = '[https://wikimap.toolforge.org/?cat='..pagenamee..'&subcats=true&subcatdepth=1&cluster=true '..getLabel('Q99232292')..']'
			hl[#hl+1] = '[https://locator-tool.toolforge.org/#/geolocate?category='..pagenamee..' '..getLabel('Q66498380')..']'
		end
	end

	hl[#hl+1] = '[https://iw.toolforge.org/kmlexport?project=commons&article='..mw.uri.encode(title.prefixedText)..' '..getLabel('P3096')..']'

	if coords and not otherplanet then
		hl[#hl+1] = '[https://wikishootme.toolforge.org/#q='..QID..'&main_commons_category='..pagenamee..' '..getLabel('Q26964791')..']'
		hl[#hl+1] = '[https://overpass-api.de/api/interpreter?data='..mw.uri.encode('[out:custom];node[wikidata='..QID..'];if(count(nodes)==0){way[wikidata='..QID..'];};if(count(ways)==0){rel[wikidata='..QID..'];};out 1;', 'PATH')..' '..getLabel('Q2505')..']'
	end

	for i, v in ipairs( hl ) do
		hl[i] = '<span style="white-space:nowrap">' .. v .. '</span>'
	end

	hl[#hl+1] = '[[Special:Search/haswbstatement:P180='..QID..'|'..i18n('search-depicted', FALLBACKLANGS)..']]'
	hl[#hl+1] = ISTAXON and '[https://commons-query.wikimedia.org/#%23defaultView%3AImageGrid%0ASELECT%20%3Ffile%20%3Fimage%0AWITH%20%7B%0A%20%20SELECT%20%3Fitem%20WHERE%20%7B%0A%20%20%20%20SERVICE%20%3Chttps%3A%2F%2Fquery.wikidata.org%2Fsparql%3E%20%7B%0A%20%20%20%20%20%20%20%20%3Fitem%20wdt%3AP171%2Fwdt%3AP171%2a%20wd%3A'..QID..'.%0A%20%20%20%20%7D%20%0A%20%20%7D%0A%7D%20AS%20%25get_items%0AWHERE%20%7B%0A%20%20INCLUDE%20%25get_items%0A%20%20%3Ffile%20wdt%3AP180%20%3Fitem%20.%0A%20%20%3Ffile%20schema%3AcontentUrl%20%3Furl%20.%0A%20%20BIND%28IRI%28CONCAT%28%22http%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FSpecial%3AFilePath%2F%22%2C%20wikibase%3AdecodeUri%28SUBSTR%28STR%28%3Furl%29%2C53%29%29%29%29%20AS%20%3Fimage%29%0A%7D '..i18n('taxon-depicted', FALLBACKLANGS)..']'

	return table.concat{
		'<tr class="gdinfo_nomobile">',
			'<td colspan=2 style="text-align: center"><small>',
				'<div class="hlist hlist-separated"><ul>',
					'<li>' .. table.concat(hl, '</li><li>') .. '</li>',
				'</ul></div>',
			'</small></td>',
		'</tr>',
	}
end

local function footer()
	return (config.authoritycontrol or config.helperlinks) and table.concat{
		'<tr><td colspan=2>',
			'<table style="width:100%" id="gdinfo_ac" class="mw-collapsible">',
				authoritycontrol(),
				helperlinks(),
			'</table>',
		'</td></tr>',
	} or ''
end

--- @param eid string: Gratisdata entity ID starting with Q or P
local function entityLink( eid )
	local label = getLabel( eid, true )
	local ns = ( eid:sub(1, 1) == 'P' ) and 'Property:' or ''
	return '[[d:'..ns..eid..'|'..label..' <small>('..eid..')</small>]]'
end

--- Generates [[Template:Gratisdata Infobox/doc/properties]]
function p.doc()
	local out = {}
	for _, group in ipairs( property_groups ) do
		out[#out+1] = '<h2>' .. group.groupname .. '</h2>'
		if group.comment then
			out[#out+1] = frame:preprocess( group.comment )
		end

		if group.P3_allowed_values then
			local classes = {}
			for _, class in ipairs( group.P3_allowed_values ) do
				classes[#classes+1] = entityLink( class )
			end
			out[#out+1] = 'This group is only shown if the connected Gratisdata item is an instance of ' .. table.concat(classes, ' or ') .. '.'
		elseif group.humans_allowed then
			out[#out+1] = 'This group is always shown.'
		end

		local props = {}
		for _, pid in ipairs( group.pids ) do
			props[#props+1] = entityLink( pid )
		end
		out[#out+1] = table.concat( props, ' • ' )
	end

	-- authority control
	out[#out+1] = '<h2>'..getLabel('Q549')..'</h2>'
	out[#out+1] = 'This group is always shown.'
	for _, group in ipairs( externalIDs ) do
		out[#out+1] = '<h3>' .. group.groupname .. '</h3>'
		local props = {}
		for _, pid in ipairs( group.pids ) do
			props[#props+1] = entityLink( pid )
		end
		out[#out+1] = table.concat( props, ' • ' )
	end

	return table.concat( out, '\n\n' )
end

local function configure( t )
	config.defaultsort      = t['defaultsort']           == 'y'
	config.interwiki        = t['interwiki']             == 'yes'
	config.autocat          = t['autocat']               == 'yes'
	config.trackingcats     = t['trackingcats']          == 'yes'
	config.uploadlink       = t['conf_upload']           == 'yes'
	config.sitelinks        = t['conf_sitelinks']        == 'yes'
	config.authoritycontrol = t['conf_authoritycontrol'] == 'yes'
	config.helperlinks      = t['conf_helperlinks']      == 'yes'

	if t['conf_coordtemplate'] then config.coordtemplate = tonumber( t['conf_coordtemplate'] ) end
	if t['conf_mapwidth'] then config.mapwidth = t['conf_mapwidth'] end
	if t['conf_mapheight'] then config.mapheight = t['conf_mapheight'] end
	if t['conf_imagesize'] then config.imagesize = t['conf_imagesize'] end

	if t['spf'] then config.spf = t['spf'] end
	if t['fgd'] then config.fgd = t['fgd'] end
	if t['osd'] then config.osd = t['osd'] end
	if t['noicon'] then config.noicon = t['noicon'] end
end

function p.main( frame )
	MYLANG = frame:callParserFunction( 'int', 'lang' ) or "en"
	LANG = mw.language.new( MYLANG )
	FALLBACKLANGS = { MYLANG, unpack(mw.language.getFallbacksFor(MYLANG)) }
	QID = frame.args[1]
	ITEM = mw.wikibase.getEntity( QID )
	if not ITEM then
		return '[[Category:Uses of Gratisdata Infobox for deleted Gratisdata items]]'
	end
	CLAIMS = ITEM.claims
	if not CLAIMS then
		local msg = i18n('noclaims', FALLBACKLANGS):gsub('$1', '[[d:'..QID..'|'..QID..']]' )
		return '[[Category:Uses of Gratisdata Infobox with no claims]]<table id="gdinfobox" class="fileinfotpl-type-information vevent infobox mw-content-'..LANG:getDir()..'"><tr><td><strong class="error">'..msg..'</td></strong></tr>'
	end

	-- identifying a taxon by checking whether it has a taxon property is faster than checking whether its P3 value is a subclass of taxon
	ISTAXON = CLAIMS['P592'] or CLAIMS['P591'] or CLAIMS['P594'] or CLAIMS['P612']
	local parentframe = frame:getParent()
	if parentframe then
		configure( parentframe.args )
	end

	for class in iclaims( ITEM:getBestStatements('P3') ) do
		INSTANCEOF[class.id] = true
	end

	local out = {
		metadata(),
		'<table id="gdinfobox" class="fileinfotpl-type-information vevent infobox mw-collapsible mw-content-'..LANG:getDir()..'">',
			'<caption class="fn org" id="gdinfoboxcaption">',
				'<b>' .. (ITEM:getLabel() or QID) .. '&nbsp;</b>',
			'</caption>',
			header( config.uploadlink ),
			body(),
			footer(),
			pencil(),
		'</table>',
	}
	if config.trackingcats and os.clock() > 2.5 then -- longer than 2.5 seconds
		out[#out+1] = '[[Category:Uses of Gratisdata Infobox with bad performance]]'
	end
	return table.concat( out )
end

function p.debug( qid )
	frame.args = { qid or 'Q2' }
	return p.main( frame )
end

return p

-- Credits:
-- Original authors: Mike Peel with contributions by Jura1
-- 2022 rewrite for Gratisdata: Ugochimobi