Module:Preamble

From Porn Base Central, the free encyclopedia of gay porn
Jump to navigation Jump to search

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

-- Lead section. Wikibase access goes through Module:PBD and mw.wikibase.
--
-- p._main(args) is safe to call from other modules. Omit flags for article
-- behavior. Any nonempty value enables a flag:
--   from / qid / eid  item id (otherwise the current page sitelink)
--   nocat             do not add [[Category:...]]
--   noref             do not emit Cite <ref> via PBD
--   nonotice          do not append the incomplete-preamble editor box
--   debug             always show the editor box

local PBD = require('Module:PBD')
local Dates = require('Module:Preamble/dates')
local Places = require('Module:Preamble/places')
local AOrAn = require('Module:A or an')
local getArgs = require('Module:Arguments').getArgs
local Config = mw.loadData('Module:Preamble/config')

local p = {}

local function trim(s)
	if s == nil then
		return ''
	end
	return mw.text.trim(tostring(s))
end

local function nonempty(s)
	return trim(s) ~= ''
end

local function pbd(command, flags, propertyId, extra)
	extra = extra or {}
	local args = {}
	for _, flag in ipairs(flags or {}) do
		-- noref is a Preamble flag, not a PBD argument. Drop the references
		-- command so Cite never registers footnotes on this parse.
		if extra.noref and (flag == 'references' or flag == 'reference') then
			-- skip
		else
			args[#args + 1] = flag
		end
	end
	args[#args + 1] = propertyId
	for key, value in pairs(extra) do
		if key ~= 'noref' and key ~= 'nocat' and key ~= 'nonotice' then
			args[key] = value
		end
	end
	local ok, value = pcall(function()
		return PBD['_' .. command](args)
	end)
	if not ok or value == nil then
		return ''
	end
	return trim(value)
end

local function extraArgs(opts)
	local extra = {}
	if opts.eid and opts.eid ~= '' then
		extra.eid = opts.eid
	end
	if opts.noref then
		extra.noref = true
	end
	return extra
end

local function qidRank(list)
	local rank = {}
	for i, qid in ipairs(list or {}) do
		rank[qid] = i
	end
	return rank
end

local OCC = Config.occupations or {}
local CAREER = OCC.career or {}
local LEAD_RANK = qidRank(OCC.lead)
local MODEL_RANK = qidRank(OCC.models)
local FORMER_JOB = qidRank(CAREER.former_jobs)
local GENERIC_MODEL_QID = OCC.generic_model or 'Q527'

local function visibleLabel(link)
	return link:match('%[%[[^%]|]+|([^%]]+)%]%]%s*$')
		or link:match('%[%[([^%]|]+)%]%]%s*$')
		or link
end

local function withLabel(link, label)
	local target = link:match('%[%[([^%]|]+)|') or link:match('%[%[([^%]]+)%]%]')
	if target then
		return '[[' .. target .. '|' .. label .. ']]'
	end
	return label
end

local function modelStem(label)
	label = trim(label)
	if label == 'model' then
		return ''
	end
	return label:match('^(.*) model$')
end

local function isModelJob(item)
	if MODEL_RANK[item.qid] then
		return true
	end
	return modelStem(visibleLabel(item.link)) ~= nil
end

local function jobItems(jobs)
	local items = {}
	local seen = {}
	for i, qid in ipairs(jobs.qids or {}) do
		if qid and not seen[qid] then
			seen[qid] = true
			items[#items + 1] = {
				qid = qid,
				link = jobs.texts[i],
				ref = (jobs.refs and jobs.refs[i]) or '',
				index = i
			}
		end
	end
	return items
end

local function splitJobs(items)
	local leadByQid, mid, models = {}, {}, {}
	for _, item in ipairs(items) do
		if LEAD_RANK[item.qid] then
			leadByQid[item.qid] = item
		elseif isModelJob(item) then
			models[#models + 1] = item
		else
			mid[#mid + 1] = item
		end
	end
	table.sort(models, function(a, b)
		local ra = MODEL_RANK[a.qid] or (100 + a.index)
		local rb = MODEL_RANK[b.qid] or (100 + b.index)
		if ra ~= rb then
			return ra < rb
		end
		return a.index < b.index
	end)
	-- Bare "model" is redundant when a more specific *model is present.
	if #models > 1 then
		local filtered = {}
		for _, item in ipairs(models) do
			if item.qid ~= GENERIC_MODEL_QID and modelStem(visibleLabel(item.link)) ~= '' then
				filtered[#filtered + 1] = item
			end
		end
		if #filtered > 0 then
			models = filtered
		end
	end
	local prefix = {}
	for _, qid in ipairs(OCC.lead or {}) do
		if leadByQid[qid] then
			prefix[#prefix + 1] = leadByQid[qid]
		end
	end
	for _, item in ipairs(mid) do
		prefix[#prefix + 1] = item
	end
	return prefix, models
end

-- "adult model" + "glamour model" → "[[…|adult]] and [[…|glamour model]]"
local function formatModelTail(models)
	if #models == 0 then
		return ''
	end
	local parts = {}
	for i, item in ipairs(models) do
		if i < #models then
			local stem = modelStem(visibleLabel(item.link))
			parts[#parts + 1] = ((stem and stem ~= '') and withLabel(item.link, stem) or item.link) .. (item.ref or '')
		else
			parts[#parts + 1] = item.link .. (item.ref or '')
		end
	end
	return mw.text.listToText(parts, ', ', ' and ')
end

local function formatJobList(jobs, former)
	jobs = jobs or { qids = {}, texts = {} }
	local prefix, models = splitJobs(jobItems(jobs))
	local prefixLinks = {}
	for _, item in ipairs(prefix) do
		local link = item.link
		if former and FORMER_JOB[item.qid] then
			link = 'former ' .. link
		end
		prefixLinks[#prefixLinks + 1] = link .. (item.ref or '')
	end
	local modelTail = formatModelTail(models)
	if modelTail == '' then
		return mw.text.listToText(prefixLinks, ', ', ' and ')
	end
	if #prefixLinks == 0 then
		return modelTail
	end
	-- Two or more *model types already have "and" inside the tail.
	if #models > 1 then
		return table.concat(prefixLinks, ', ') .. ', ' .. modelTail
	end
	prefixLinks[#prefixLinks + 1] = modelTail
	return mw.text.listToText(prefixLinks, ', ', ' and ')
end

local NAT = Config.nationality or {}
local AMERICAN_QID = NAT.american or 'Q165'
local BRITISH_QID = NAT.british or 'Q505'
local UNITED_STATES_QID = NAT.united_states or 'Q143'
local COUNTRY_DEMONYM = NAT.country_demonym or {}
local SKIP_NAT = qidRank(NAT.skip)
local UK_NATION = qidRank(NAT.uk)

local function qidIn(list, qid)
	for _, id in ipairs(list or {}) do
		if id == qid then
			return true
		end
	end
	return false
end

local function bornInUnitedStates(entity)
	if qidIn(Places.linkedValues(entity, 'P144').qids, UNITED_STATES_QID) then
		return true
	end
	return qidIn(Places.linkedValues(entity, 'P20').qids, UNITED_STATES_QID)
end

local function birthDemonymQid(entity, natSet)
	local function match(qids)
		for _, qid in ipairs(qids or {}) do
			local dem = COUNTRY_DEMONYM[qid]
			if dem and natSet[dem] then
				return dem
			end
			if natSet[qid] then
				return qid
			end
		end
		return nil
	end
	return match(Places.linkedValues(entity, 'P144').qids)
		or match(Places.linkedValues(entity, 'P20').qids)
end

-- "English Canadian" refines "Canadian"; keep the shorter base in the lead.
local function isNatRefinement(label, base)
	label = mw.ustring.lower(trim(label))
	base = mw.ustring.lower(trim(base))
	if label == '' or base == '' or label == base or mw.ustring.len(base) >= mw.ustring.len(label) then
		return false
	end
	local escaped = mw.ustring.gsub(base, '([^%w])', '%%%1')
	return mw.ustring.find(label, '%f[%w]' .. escaped .. '%f[%W]') ~= nil
end

local function filterNationalities(nats)
	local items = {}
	for i, qid in ipairs(nats.qids or {}) do
		items[#items + 1] = {
			qid = qid,
			link = nats.texts[i],
			label = visibleLabel(nats.texts[i])
		}
	end
	local present = {}
	for _, item in ipairs(items) do
		present[item.qid] = true
	end
	local keep = {}
	for i, item in ipairs(items) do
		local drop = false
		for j, other in ipairs(items) do
			if i ~= j and isNatRefinement(item.label, other.label) then
				drop = true
				break
			end
		end
		if not drop and SKIP_NAT[item.qid] then
			for j, other in ipairs(items) do
				if i ~= j and not SKIP_NAT[other.qid] then
					drop = true
					break
				end
			end
		end
		-- English / Welsh / Scottish / Northern Irish refine British, not each other.
		if not drop and UK_NATION[item.qid] and present[BRITISH_QID] then
			drop = true
		end
		if not drop then
			keep[#keep + 1] = item
		end
	end
	return keep
end

-- Dual+ nationality: American-British if US-born, British-American otherwise.
local function formatNationality(entity)
	local nats = Places.linkedValues(entity, 'P131')
	if nats.empty then
		return ''
	end
	local items = filterNationalities(nats)
	if #items == 0 then
		return nats.texts[1] or ''
	end
	if #items == 1 then
		return items[1].link
	end
	local byQid = {}
	local natSet = {}
	local qids = {}
	for _, item in ipairs(items) do
		byQid[item.qid] = item.link
		natSet[item.qid] = true
		qids[#qids + 1] = item.qid
	end
	local ordered = {}
	local used = {}
	local function push(qid)
		if qid and byQid[qid] and not used[qid] then
			used[qid] = true
			ordered[#ordered + 1] = byQid[qid]
		end
	end
	local hasAmerican = natSet[AMERICAN_QID]
	if hasAmerican and bornInUnitedStates(entity) then
		push(AMERICAN_QID)
		for _, qid in ipairs(qids) do
			push(qid)
		end
	else
		push(birthDemonymQid(entity, natSet))
		for _, qid in ipairs(qids) do
			if qid ~= AMERICAN_QID then
				push(qid)
			end
		end
		if hasAmerican then
			push(AMERICAN_QID)
		end
	end
	return table.concat(ordered, '-')
end

local function getEntity(eid)
	if eid and eid ~= '' then
		return mw.wikibase.getEntity(eid)
	end
	return mw.wikibase.getEntity()
end

local function isMain()
	return mw.title.getCurrentTitle().namespace == 0
end

local function cat(name, nocat)
	if nocat or not isMain() or not name or name == '' then
		return ''
	end
	return '[[Category:' .. name .. ']]'
end

local function join(parts, sep)
	local out = {}
	for _, part in ipairs(parts) do
		if nonempty(part) then
			out[#out + 1] = part
		end
	end
	return table.concat(out, sep)
end

-- Same as Pornbasedata monolingualLangTemplate=lang: {{lang-ru|…}} → "Russian: …"
local function expandLang(lang, text)
	local frame = mw.getCurrentFrame()
	if frame then
		local ok, html = pcall(function()
			return frame:expandTemplate{ title = 'lang-' .. lang, args = { text } }
		end)
		if ok and html and html ~= '' then
			return html
		end
		ok, html = pcall(function()
			return frame:expandTemplate{ title = 'Lang', args = { lang, text } }
		end)
		if ok and html and html ~= '' then
			return html
		end
	end
	return '<span lang="' .. lang .. '">' .. text .. '</span>'
end

local function trackQids(opts, result)
	if not opts or not opts.catQids or not result then
		return
	end
	for _, qid in ipairs(result.qids or {}) do
		opts.catQids[#opts.catQids + 1] = qid
	end
end

local function formatNativeNames(entity, skipText)
	if not entity or not entity.claims or not entity.claims.P13 then
		return ''
	end
	skipText = trim(skipText)
	local names = {}
	for _, statement in ipairs(Dates.statements(entity, 'P13')) do
		local snak = statement.mainsnak
		if snak and snak.snaktype == 'value' and snak.datavalue and snak.datavalue.value then
			local value = snak.datavalue.value
			local lang = value.language
			local text = value.text
			if text and text ~= '' and lang and lang ~= 'en' and text ~= skipText then
				names[#names + 1] = expandLang(lang, text)
			end
		end
	end
	return table.concat(names, ', ')
end

local function formatVital(entity, opts)
	local birthYear = Dates.yearForPlaces(entity, 'P9')
	local deathYear = Dates.yearForPlaces(entity, 'P247')
	local birth = Dates.formatBirth(entity, opts)
	local death = Dates.formatDeath(entity, opts)
	local extra = extraArgs(opts)

	local placeBirth = Places.linkedValues(entity, 'P20')
	if placeBirth.empty then
		placeBirth = Places.formatProperty(entity, 'P20', {
			eid = opts.eid,
			atYear = birthYear,
			noref = opts.noref
		})
	end
	local countryBirth = Places.linkedValues(entity, 'P144')
	if countryBirth.empty then
		countryBirth = Places.countryOnly(entity, 'P144', {
			eid = opts.eid,
			noref = opts.noref
		})
	end
	-- Death places stay plain text: Category:Barcelona is for births, not deaths.
	local placeDeath = Places.plainValues(entity, 'P23')
	if placeDeath.empty then
		placeDeath = Places.formatProperty(entity, 'P23', {
			eid = opts.eid,
			atYear = deathYear or birthYear,
			plain = true,
			noref = opts.noref
		})
	end

	trackQids(opts, placeBirth)
	trackQids(opts, countryBirth)

	local placeBirthText = placeBirth.text or ''
	local countryBirthText = countryBirth.text or ''
	local placeDeathText = placeDeath.text or ''

	if nonempty(countryBirthText) then
		local skip = false
		for _, qid in ipairs(countryBirth.qids or {}) do
			if Places.containsQid(placeBirth, qid) then
				skip = true
				break
			end
		end
		if skip then
			countryBirthText = ''
		end
	end

	local hasDate = not birth.empty
	local hasBirthName = nonempty(pbd('property', { 'multilanguage' }, 'P5', extra))
	local birthBits = {}
	if hasDate then
		birthBits[#birthBits + 1] = birth.text
	end
	if nonempty(placeBirthText) then
		-- Old template: "in PLACE" when a birth name (P5) is present, even with a date.
		if hasDate and not hasBirthName then
			birthBits[#birthBits + 1] = placeBirthText
		else
			birthBits[#birthBits + 1] = 'in ' .. placeBirthText
		end
	end
	if nonempty(countryBirthText) then
		if #birthBits > 0 then
			birthBits[#birthBits + 1] = countryBirthText
		else
			birthBits[#birthBits + 1] = 'in ' .. countryBirthText
		end
	end

	local extraCats = ''
	if birth.empty and (nonempty(placeBirthText) or nonempty(countryBirthText) or not death.empty) then
		extraCats = cat('Date of birth needed', opts.nocat)
	end

	local birthText = join(birthBits, ', ')
	local deathText = join({ death.text, placeDeathText }, ', ')
	local hasBirth = nonempty(birthText)
	local hasDeathSide = nonempty(deathText)

	if not hasBirth and not hasDeathSide then
		return { text = extraCats, hasDeath = not death.empty, hasExactDate = false }
	end
	if hasBirth and hasDeathSide then
		-- Dash is the lifespan mark; "died" labels the second cluster when places are present.
		return { text = birthText .. ' — died ' .. deathText .. extraCats, hasDeath = true, hasExactDate = hasDate }
	end
	if hasDeathSide then
		return { text = 'died ' .. deathText .. extraCats, hasDeath = true, hasExactDate = false }
	end
	return { text = birthText .. extraCats, hasDeath = false, hasExactDate = hasDate }
end

local function onlyCategories(s)
	return trim((s or ''):gsub('%[%[Category:[^%]]+%]%]', '')) == ''
end

local function formatNameParts(opts, native, vitalText)
	local aliases = pbd('properties', { 'multilanguage' }, 'P140', extraArgs(opts))
	local real753 = pbd('property', { 'multilanguage', 'references', 'normal+' }, 'P753', extraArgs(opts))
	local real648 = pbd('property', { 'multilanguage', 'references', 'normal+' }, 'P648', extraArgs(opts))
	local birthName = pbd('property', { 'multilanguage', 'references', 'normal+' }, 'P5', extraArgs(opts))

	local afterAka = {}
	if nonempty(real753) then
		afterAka[#afterAka + 1] = "real name '''''" .. real753 .. "'''''"
	end
	if nonempty(birthName) then
		if nonempty(real648) then
			afterAka[#afterAka + 1] = "real name '''''" .. real648 .. "'''''"
			afterAka[#afterAka + 1] = "né '''''" .. birthName .. "'''''"
		else
			afterAka[#afterAka + 1] = "born '''''" .. birthName .. "'''''"
		end
	elseif nonempty(real648) then
		afterAka[#afterAka + 1] = "real name '''''" .. real648 .. "'''''"
	end
	if nonempty(native) then
		afterAka[#afterAka + 1] = native
	end
	if nonempty(vitalText) then
		if onlyCategories(vitalText) then
			afterAka[#afterAka + 1] = vitalText
		elseif nonempty(birthName) or vitalText:match('^died ') then
			afterAka[#afterAka + 1] = vitalText
		else
			afterAka[#afterAka + 1] = 'born ' .. vitalText
		end
	end

	if nonempty(aliases) then
		local rest = join(afterAka, ', ')
		if nonempty(rest) then
			return "(<abbr title=\"also known as\">a.k.a.</abbr> '''''" .. aliases .. "'''''; " .. rest .. ')'
		end
		return "(<abbr title=\"also known as\">a.k.a.</abbr> '''''" .. aliases .. "''''')"
	end

	if #afterAka > 0 then
		return '(' .. join(afterAka, ', ') .. ')'
	end
	return ''
end

local function formatOccupation(entity, opts, hasDeath, nocat)
	local nats = Places.linkedValues(entity, 'P131')
	local nationality = formatNationality(entity)
	local orientation = Places.linkedValues(entity, 'P347')
	local jobs = Places.linkedValues(entity, 'P124', {
		withRefs = not opts.noref,
		eid = opts.eid,
		noref = opts.noref
	})
	local retired = qidIn(Places.linkedValues(entity, CAREER.property or 'P154').qids, CAREER.retired or 'Q194')
	local hasActorJob = false
	for _, qid in ipairs(jobs.qids or {}) do
		if FORMER_JOB[qid] then
			hasActorJob = true
			break
		end
	end
	-- "former" = left the industry while alive. Death already uses "was"; do not stack both.
	local former = hasActorJob and retired and not hasDeath
	local jobList = formatJobList(jobs, former)
	trackQids(opts, nats)
	trackQids(opts, orientation)
	trackQids(opts, jobs)

	local verb = hasDeath and 'was' or 'is'
	local bits = {}
	if nonempty(nationality) then
		bits[#bits + 1] = nationality
	end
	if nonempty(orientation.text) then
		bits[#bits + 1] = orientation.text
	end
	if nonempty(jobList) then
		bits[#bits + 1] = jobList
	end
	if #bits == 0 then
		return ''
	end
	-- a/an on the first word after the verb: nationality, else orientation, else occupation.
	bits[1] = AOrAn._main({ bits[1] })
	local deadCat = hasDeath and cat('Dead porn stars', nocat) or ''
	return verb .. ' ' .. join(bits, ' ') .. deadCat .. '.'
end

local function claimHasValue(entity, propertyId)
	if not entity then
		return false
	end
	for _, statement in ipairs(Dates.statements(entity, propertyId)) do
		local snaktype = statement.mainsnak and statement.mainsnak.snaktype
		if snaktype == 'value' or snaktype == 'somevalue' then
			return true
		end
	end
	return false
end

local function propLink(row)
	return '[[pbd:Property:' .. row.id .. '|' .. row.label .. ' (' .. row.id .. ')]]'
end

local function checkRows(entity, rows, missing, present)
	for _, row in ipairs(rows or {}) do
		local label = propLink(row)
		if claimHasValue(entity, row.id) then
			present[#present + 1] = label
		elseif missing then
			missing[#missing + 1] = label
		end
	end
end

local function missingRequired(entity)
	local schema = Config.properties or {}
	local missing, present = {}, {}
	checkRows(entity, schema.required, missing, present)
	if claimHasValue(entity, 'P247') then
		checkRows(entity, schema.required_if_dead, missing, present)
	end
	checkRows(entity, schema.optional, nil, present)
	return missing, present
end

local function formatIncomplete(eid, entity, nocat, missing, present)
	local bits = {
		'<div class="preamble-incomplete" style="border:1px solid #a2a9b1;background:#f8f9fa;padding:0.6em 0.8em;margin:0.5em 0">'
	}
	if not entity then
		bits[#bits + 1] = "'''Preamble''' cannot be generated: this page is not linked to a Porn Base Data item. "
		bits[#bits + 1] = 'Add a sitelink from the item to this article, or call the module with <code>|from=Q…</code>.'
	else
		local qid = (eid and eid ~= '') and eid or (mw.wikibase.getEntityIdForCurrentPage() or '')
		bits[#bits + 1] = "'''Preamble''' is incomplete"
		if qid ~= '' then
			bits[#bits + 1] = ' for [[pbd:' .. qid .. '|' .. qid .. ']]'
		end
		bits[#bits + 1] = '.'
		if missing and #missing > 0 then
			bits[#bits + 1] = ' Missing required: ' .. table.concat(missing, ', ') .. '.'
		end
		if present and #present > 0 then
			bits[#bits + 1] = ' Filled: ' .. table.concat(present, ', ') .. '.'
		end
		if claimHasValue(entity, 'P247') then
			bits[#bits + 1] = ' Date of death (P247) is present → verb <i>was</i>.'
		else
			bits[#bits + 1] = ' No date of death (P247) → verb <i>is</i>.'
		end
	end
	bits[#bits + 1] = '</div>'
	return table.concat(bits) .. cat('Articles with incomplete preamble', nocat)
end

function p._main(args)
	args = args or {}
	local eid = trim(args.from or args.qid or args.eid)
	if eid == '' then
		eid = mw.wikibase.getEntityIdForCurrentPage() or ''
	end
	local nocat = nonempty(args.nocat)
	local noref = nonempty(args.noref)
	local nonotice = nonempty(args.nonotice)
	local debug = nonempty(args.debug)
	local opts = { eid = eid ~= '' and eid or nil, nocat = nocat, noref = noref, catQids = {} }
	local entity = getEntity(opts.eid)
	if not entity then
		if nonotice then
			return ''
		end
		return formatIncomplete(eid, nil, nocat)
	end

	local missing, present = missingRequired(entity)
	local name = pbd('property', { 'multilanguage' }, 'P7', extraArgs(opts))
	local native = formatNativeNames(entity, name)
	local vital = formatVital(entity, opts)
	local hasDeath = vital.hasDeath
		or nonempty(pbd('property', { 'normal+' }, 'P247', extraArgs(opts)))
	local paren = formatNameParts(opts, native, vital.text)
	local occupation = formatOccupation(entity, opts, hasDeath, nocat)

	local lead = join({
		nonempty(name) and "'''" .. name .. "'''" or nil,
		paren,
		occupation
	}, ' ')

	local notice = ''
	if not nonotice and (debug or #missing > 0 or lead == '') then
		notice = formatIncomplete(eid, entity, nocat, missing, present)
	end
	-- A lead without P7 is not a preamble; show the editor notice only.
	if lead == '' or name == '' then
		return notice
	end
	return lead .. Places.categoriesFromQids(opts.catQids, nocat) .. notice
end

function p.main(frame)
	return p._main(getArgs(frame, {
		wrappers = { 'Template:Preamble', 'Template:Preamble-test' }
	}))
end

return p