Module:FamilyTree

From KB Lexicon
Revision as of 21:38, 29 March 2026 by Wylder Merrow (talk | contribs)

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

local p = {}

local cargo = mw.ext.cargo
local html = mw.html

-- =========================================
-- Helpers
-- =========================================

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

local function isRealValue(v)
	v = trim(v)
	if not v then
		return false
	end

	local lowered = mw.ustring.lower(v)
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'
end

local function addUnique(list, value)
	if not isRealValue(value) then
		return
	end
	for _, existing in ipairs(list) do
		if existing == value then
			return
		end
	end
	table.insert(list, value)
end

local function uniq(list)
	local out = {}
	local seen = {}

	for _, v in ipairs(list or {}) do
		if isRealValue(v) and not seen[v] then
			seen[v] = true
			table.insert(out, v)
		end
	end

	return out
end

local function makeLink(name)
	if not isRealValue(name) then
		return ''
	end
	return string.format('[[%s|%s]]', name, name)
end

local function getArg(frame, key)
	local v = frame.args[key]
	if isRealValue(v) then
		return trim(v)
	end

	local parent = frame:getParent()
	if parent then
		v = parent.args[key]
		if isRealValue(v) then
			return trim(v)
		end
	end

	return nil
end

local function ensurePerson(people, name)
	name = trim(name)
	if not isRealValue(name) then
		return nil
	end

	if not people[name] then
		people[name] = {
			name = name,
			displayName = name,
			gender = nil,
			birthDate = nil,
			deathDate = nil,
			status = nil,
			birthFamily = nil,
			currentFamily = nil,
			father = nil,
			mother = nil,
			adoptiveFather = nil,
			adoptiveMother = nil,
			bloodStatus = nil,
			title = nil,
			heir = nil,
			illegitimate = nil,
			adopted = nil,
			parents = {},
			children = {},
			partners = {},
			unions = {}
		}
	end

	return people[name]
end

local function sortNames(people, names)
	table.sort(names, function(a, b)
		local ad = (people[a] and people[a].displayName) or a
		local bd = (people[b] and people[b].displayName) or b
		return mw.ustring.lower(ad) < mw.ustring.lower(bd)
	end)
end

local function yesNo(val)
	if val == nil then
		return nil
	end

	local s = mw.ustring.lower(tostring(val))
	if s == '1' or s == 'true' or s == 'yes' then
		return 'Yes'
	end
	if s == '0' or s == 'false' or s == 'no' then
		return 'No'
	end
	return tostring(val)
end

-- =========================================
-- Data loading
-- =========================================

local function queryCharacters()
	local results = cargo.query(
		'Characters',
		'Page,DisplayName,Gender,BirthDate,DeathDate,Status,BirthFamily,CurrentFamily,Father,Mother,AdoptiveFather,AdoptiveMother,BloodStatus,Title,Heir,Illegitimate,Adopted',
		{ limit = 5000 }
	)

	local people = {}

	for _, row in ipairs(results) do
		local page = trim(row.Page)

		if isRealValue(page) then
			people[page] = {
				name = page,
				displayName = trim(row.DisplayName) or page,
				gender = trim(row.Gender),
				birthDate = trim(row.BirthDate),
				deathDate = trim(row.DeathDate),
				status = trim(row.Status),
				birthFamily = trim(row.BirthFamily),
				currentFamily = trim(row.CurrentFamily),
				father = trim(row.Father),
				mother = trim(row.Mother),
				adoptiveFather = trim(row.AdoptiveFather),
				adoptiveMother = trim(row.AdoptiveMother),
				bloodStatus = trim(row.BloodStatus),
				title = trim(row.Title),
				heir = row.Heir,
				illegitimate = row.Illegitimate,
				adopted = row.Adopted,
				parents = {},
				children = {},
				partners = {},
				unions = {}
			}
		end
	end

	return people
end

local function loadParentChild(people)
	local results = cargo.query(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{ limit = 5000 }
	)

	for _, row in ipairs(results) do
		local child = trim(row.Child)
		local p1 = trim(row.Parent1)
		local p2 = trim(row.Parent2)

		if isRealValue(child) then
			ensurePerson(people, child)

			if isRealValue(p1) then
				ensurePerson(people, p1)
				addUnique(people[child].parents, p1)
				addUnique(people[p1].children, child)
			end

			if isRealValue(p2) then
				ensurePerson(people, p2)
				addUnique(people[child].parents, p2)
				addUnique(people[p2].children, child)
			end
		end
	end
end

local function loadCharacterParentFallbacks(people)
	for _, person in pairs(people) do
		if isRealValue(person.father) then
			ensurePerson(people, person.father)
			addUnique(person.parents, person.father)
			addUnique(people[person.father].children, person.name)
		end

		if isRealValue(person.mother) then
			ensurePerson(people, person.mother)
			addUnique(person.parents, person.mother)
			addUnique(people[person.mother].children, person.name)
		end

		if isRealValue(person.adoptiveFather) then
			ensurePerson(people, person.adoptiveFather)
			addUnique(person.parents, person.adoptiveFather)
			addUnique(people[person.adoptiveFather].children, person.name)
		end

		if isRealValue(person.adoptiveMother) then
			ensurePerson(people, person.adoptiveMother)
			addUnique(person.parents, person.adoptiveMother)
			addUnique(people[person.adoptiveMother].children, person.name)
		end
	end
end

local function loadUnions(people)
	local results = cargo.query(
		'Unions',
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',
		{ limit = 5000 }
	)

	for _, row in ipairs(results) do
		local unionID = trim(row.UnionID)
		local p1 = trim(row.Partner1)
		local p2 = trim(row.Partner2)

		if isRealValue(p1) then
			ensurePerson(people, p1)
		end
		if isRealValue(p2) then
			ensurePerson(people, p2)
		end

		if isRealValue(p1) and isRealValue(p2) then
			addUnique(people[p1].partners, p2)
			addUnique(people[p2].partners, p1)

			table.insert(people[p1].unions, {
				unionID = unionID,
				partner = p2,
				unionType = trim(row.UnionType),
				status = trim(row.Status),
				startDate = trim(row.StartDate),
				endDate = trim(row.EndDate),
				marriageDate = trim(row.MarriageDate),
				divorceDate = trim(row.DivorceDate),
				engagementDate = trim(row.EngagementDate)
			})

			table.insert(people[p2].unions, {
				unionID = unionID,
				partner = p1,
				unionType = trim(row.UnionType),
				status = trim(row.Status),
				startDate = trim(row.StartDate),
				endDate = trim(row.EndDate),
				marriageDate = trim(row.MarriageDate),
				divorceDate = trim(row.DivorceDate),
				engagementDate = trim(row.EngagementDate)
			})
		end
	end
end

local function finalizePeople(people)
	for _, person in pairs(people) do
		person.parents = uniq(person.parents)
		person.children = uniq(person.children)
		person.partners = uniq(person.partners)
	end
end

local function loadData()
	local people = queryCharacters()
	loadParentChild(people)
	loadCharacterParentFallbacks(people)
	loadUnions(people)
	finalizePeople(people)
	return people
end

-- =========================================
-- Relationship helpers
-- =========================================

local function getGrandparents(people, personName)
	local out = {}
	local person = people[personName]
	if not person then
		return out
	end

	for _, parentName in ipairs(person.parents) do
		local parent = people[parentName]
		if parent then
			for _, gp in ipairs(parent.parents) do
				addUnique(out, gp)
			end
		end
	end

	return uniq(out)
end

local function getSiblings(people, personName)
	local out = {}
	local seen = {}
	local person = people[personName]

	if not person then
		return out
	end

	for _, parentName in ipairs(person.parents) do
		local parent = people[parentName]
		if parent then
			for _, childName in ipairs(parent.children) do
				if childName ~= personName and not seen[childName] then
					seen[childName] = true
					table.insert(out, childName)
				end
			end
		end
	end

	return uniq(out)
end

local function getConnectedPeople(people, personName)
	local out = {}
	local person = people[personName]
	if not person then
		return out
	end

	for _, v in ipairs(person.parents) do addUnique(out, v) end
	for _, v in ipairs(person.children) do addUnique(out, v) end
	for _, v in ipairs(person.partners) do addUnique(out, v) end

	local siblings = getSiblings(people, personName)
	for _, v in ipairs(siblings) do addUnique(out, v) end

	local grandparents = getGrandparents(people, personName)
	for _, v in ipairs(grandparents) do addUnique(out, v) end

	return uniq(out)
end

-- =========================================
-- Rendering helpers
-- =========================================

local function renderPersonBox(name, extraClass)
	if not isRealValue(name) then
		return nil
	end

	local box = html.create('div')
	box:addClass('familytree-person')
	if isRealValue(extraClass) then
		box:addClass(extraClass)
	end
	box:wikitext(makeLink(name))
	return box
end

local function renderRow(label, names, rowClass)
	names = uniq(names)
	if #names == 0 then
		return nil
	end

	local row = html.create('div')
	row:addClass('familytree-row')
	if isRealValue(rowClass) then
		row:addClass(rowClass)
	end

	row:tag('div')
		:addClass('familytree-row-label')
		:wikitext(label)

	local items = row:tag('div')
		:addClass('familytree-row-items')

	for _, name in ipairs(names) do
		local node = renderPersonBox(name)
		if node then
			items:node(node)
		end
	end

	return row
end

local function renderTreeForPerson(people, personName)
	local person = people[personName]
	if not person then
		return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(personName) .. '".'
	end

	local grandparents = getGrandparents(people, personName)
	local parents = uniq(person.parents)
	local siblings = getSiblings(people, personName)
	local partners = uniq(person.partners)
	local children = uniq(person.children)

	sortNames(people, grandparents)
	sortNames(people, parents)
	sortNames(people, siblings)
	sortNames(people, partners)
	sortNames(people, children)

	local root = html.create('div')
	root:addClass('familytree-wrapper')

	local gpRow = renderRow('Grandparents', grandparents, 'familytree-grandparents')
	if gpRow then root:node(gpRow) end

	local pRow = renderRow('Parents', parents, 'familytree-parents')
	if pRow then root:node(pRow) end

	local selfRow = html.create('div')
	selfRow:addClass('familytree-row')
	selfRow:addClass('familytree-focus-row')

	selfRow:tag('div')
		:addClass('familytree-row-label')
		:wikitext('Focus')

	selfRow:tag('div')
		:addClass('familytree-row-items')
		:node(renderPersonBox(personName, 'familytree-focus-person'))

	root:node(selfRow)

	local sRow = renderRow('Siblings', siblings, 'familytree-siblings')
	if sRow then root:node(sRow) end

	local partnerRow = renderRow('Partners', partners, 'familytree-partners')
	if partnerRow then root:node(partnerRow) end

	local childRow = renderRow('Children', children, 'familytree-children')
	if childRow then root:node(childRow) end

	return tostring(root)
end

local function renderConnectedForPerson(people, personName)
	local person = people[personName]
	if not person then
		return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(personName) .. '".'
	end

	local connected = getConnectedPeople(people, personName)
	sortNames(people, connected)

	local root = html.create('div')
	root:addClass('familytree-connected')

	root:tag('div')
		:addClass('familytree-connected-title')
		:wikitext('Connected to ' .. makeLink(personName))

	local items = root:tag('div')
		:addClass('familytree-row-items')

	for _, name in ipairs(connected) do
		items:node(renderPersonBox(name))
	end

	return tostring(root)
end

local function renderProfileForPerson(people, personName)
	local person = people[personName]
	if not person then
		return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(personName) .. '".'
	end

	local root = html.create('div')
	root:addClass('familytree-profile')

	root:tag('div')
		:addClass('familytree-profile-name')
		:wikitext(makeLink(person.name))

	local dl = root:tag('dl')

	local function addField(label, value)
		if isRealValue(value) then
			dl:tag('dt'):wikitext(label)
			dl:tag('dd'):wikitext(value)
		end
	end

	addField('Display Name', person.displayName ~= person.name and person.displayName or nil)
	addField('Title', person.title)
	addField('Gender', person.gender)
	addField('Birth Date', person.birthDate)
	addField('Death Date', person.deathDate)
	addField('Status', person.status)
	addField('Blood Status', person.bloodStatus)
	addField('Birth Family', person.birthFamily and makeLink(person.birthFamily) or nil)
	addField('Current Family', person.currentFamily and makeLink(person.currentFamily) or nil)
	addField('Heir', yesNo(person.heir))
	addField('Illegitimate', yesNo(person.illegitimate))
	addField('Adopted', yesNo(person.adopted))

	if #person.parents > 0 then
		local links = {}
		for _, name in ipairs(person.parents) do
			table.insert(links, makeLink(name))
		end
		addField('Parents', table.concat(links, ', '))
	end

	if #person.partners > 0 then
		local links = {}
		for _, name in ipairs(person.partners) do
			table.insert(links, makeLink(name))
		end
		addField('Partners', table.concat(links, ', '))
	end

	if #person.children > 0 then
		local links = {}
		for _, name in ipairs(person.children) do
			table.insert(links, makeLink(name))
		end
		addField('Children', table.concat(links, ', '))
	end

	return tostring(root)
end

-- =========================================
-- Public functions
-- =========================================

function p.tree(frame)
	local personName = getArg(frame, 'person') or mw.title.getCurrentTitle().text
	local people = loadData()
	return renderTreeForPerson(people, personName)
end

function p.profile(frame)
	local personName = getArg(frame, 'person') or mw.title.getCurrentTitle().text
	local people = loadData()
	return renderProfileForPerson(people, personName)
end

function p.connected(frame)
	local personName = getArg(frame, 'person') or mw.title.getCurrentTitle().text
	local people = loadData()
	return renderConnectedForPerson(people, personName)
end

return p