Module:FamilyTree: Difference between revisions

From KB Lexicon
(Undo revision 1885 by Wylder Merrow (talk))
Tag: Undo
No edit summary
Line 2: Line 2:


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


local function esc(value)
-- =========================================
if not value then
-- Helpers
return ''
-- =========================================
end
value = tostring(value)
value = value:gsub('\\', '\\\\')
value = value:gsub('"', '\\"')
return value
end
 
local function cargoQuery(tables, fields, args)
args = args or {}
local ok, result = pcall(function()
return cargo.query(tables, fields, args)
end)
if ok and result then
return result
end
return {}
end


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


local function formatYear(dateValue)
local function isRealValue(v)
dateValue = trim(dateValue)
v = trim(v)
if not dateValue then
if not v then return false end
return nil
 
local lowered = mw.ustring.lower(v)
if lowered == 'unknown' or lowered == 'none' or lowered == 'n/a' then
return false
end
end
return tostring(dateValue):match('^(%d%d%d%d)')
end


local function addUnique(list, seen, value)
return true
value = trim(value)
if value and not seen[value] then
seen[value] = true
table.insert(list, value)
end
end
end


local function addSet(set, value)
local function safeArray(v)
value = trim(value)
if type(v) == 'table' then
if value then
return v
set[value] = true
end
end
return {}
end
end


local function sorted(list)
local function uniq(list)
table.sort(list, function(a, b)
local seen = {}
return tostring(a):lower() < tostring(b):lower()
local out = {}
end)
return list
end


local function getCharacter(pageName)
for _, v in ipairs(list or {}) do
pageName = trim(pageName)
if isRealValue(v) and not seen[v] then
if not pageName then
seen[v] = true
return nil
table.insert(out, v)
end
end
 
local rows = cargoQuery(
'Characters',
'Page,DisplayName,BirthDate,DeathDate,Status,Gender',
{
where = 'Page="' .. esc(pageName) .. '"',
limit = 1
}
)
 
return rows[1]
end
 
local function getDisplayName(pageName)
local c = getCharacter(pageName)
if c and trim(c.DisplayName) then
return trim(c.DisplayName)
end
end
return pageName
end


local function makeLinkedName(pageName)
return out
return '[[' .. pageName .. '|' .. getDisplayName(pageName) .. ']]'
end
end


local function linkList(list)
local function makeLink(name)
local out = {}
if not isRealValue(name) then return '' end
for _, pageName in ipairs(list or {}) do
return string.format('[[%s|%s]]', name, name)
table.insert(out, makeLinkedName(pageName))
end
return table.concat(out, '<br>')
end
end


local function getParents(person)
local function addUnique(list, value)
person = trim(person)
if not isRealValue(value) then return end
if not person then
for _, existing in ipairs(list) do
return {}, nil
if existing == value then
end
return
 
end
local rows = cargoQuery(
'ParentChild',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{
where = 'Child="' .. esc(person) .. '"',
limit = 20
}
)
 
local parents = {}
local seen = {}
 
for _, row in ipairs(rows) do
addUnique(parents, seen, row.Parent1)
addUnique(parents, seen, row.Parent2)
end
end
 
table.insert(list, value)
return sorted(parents), rows[1]
end
end


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


local rows = cargoQuery(
if frame:getParent() then
'ParentChild',
v = frame:getParent().args[key]
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
if isRealValue(v) then
{
return trim(v)
where = 'Parent1="' .. esc(person) .. '" OR Parent2="' .. esc(person) .. '"',
limit = 200
}
)
 
table.sort(rows, function(a, b)
local aOrder = tonumber(a.BirthOrder) or 9999
local bOrder = tonumber(b.BirthOrder) or 9999
if aOrder == bOrder then
return tostring(a.Child):lower() < tostring(b.Child):lower()
end
end
return aOrder < bOrder
end)
local children = {}
local seen = {}
for _, row in ipairs(rows) do
addUnique(children, seen, row.Child)
end
end


return children
return nil
end
end


local function getPartners(person)
-- =========================================
person = trim(person)
-- Cargo loaders
if not person then
-- =========================================
return {}, {}
end


local rows = cargoQuery(
local function queryCharacters()
'Unions',
local results = cargo.query(
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate',
'Characters2',
'Name,DisplayName,Gender,BirthDate,DeathDate,BirthFamily,CurrentFamily,Status',
{
{
where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
where = 'Name IS NOT NULL',
limit = 100
limit = 5000
}
}
)
)


local partners = {}
local people = {}
local seen = {}


for _, row in ipairs(rows) do
for _, row in ipairs(results) do
local p1 = trim(row.Partner1)
local name = trim(row.Name)
local p2 = trim(row.Partner2)
if isRealValue(name) then
 
people[name] = {
if p1 == person then
name = name,
addUnique(partners, seen, p2)
displayName = trim(row.DisplayName) or name,
elseif p2 == person then
gender = trim(row.Gender),
addUnique(partners, seen, p1)
birthDate = trim(row.BirthDate),
deathDate = trim(row.DeathDate),
birthFamily = trim(row.BirthFamily),
currentFamily = trim(row.CurrentFamily),
status = trim(row.Status),
parents = {},
children = {},
partners = {},
unions = {}
}
end
end
end
end


return sorted(partners), rows
return people
end
end


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


local targetRows = cargoQuery(
if not people[name] then
'ParentChild',
people[name] = {
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
name = name,
{
displayName = name,
where = 'Child="' .. esc(person) .. '"',
gender = nil,
limit = 10
birthDate = nil,
deathDate = nil,
birthFamily = nil,
currentFamily = nil,
status = nil,
parents = {},
children = {},
partners = {},
unions = {}
}
}
)
if not targetRows[1] then
return { person }
end
end


local targetUnion = trim(targetRows[1].UnionID)
return people[name]
local targetP1 = trim(targetRows[1].Parent1)
end
local targetP2 = trim(targetRows[1].Parent2)
 
local whereParts = {}
if targetUnion then
table.insert(whereParts, 'UnionID="' .. esc(targetUnion) .. '"')
end
if targetP1 and targetP2 then
table.insert(whereParts, '(Parent1="' .. esc(targetP1) .. '" AND Parent2="' .. esc(targetP2) .. '")')
end
 
if #whereParts == 0 then
return { person }
end


local rows = cargoQuery(
local function loadParentChild(people)
local results = cargo.query(
'ParentChild',
'ParentChild',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{
{
where = table.concat(whereParts, ' OR '),
where = 'Child IS NOT NULL',
limit = 100
limit = 5000
}
}
)
)


table.sort(rows, function(a, b)
for _, row in ipairs(results) do
local aOrder = tonumber(a.BirthOrder) or 9999
local child = trim(row.Child)
local bOrder = tonumber(b.BirthOrder) or 9999
local p1 = trim(row.Parent1)
if aOrder == bOrder then
local p2 = trim(row.Parent2)
return tostring(a.Child):lower() < tostring(b.Child):lower()
end
return aOrder < bOrder
end)


local people = {}
if isRealValue(child) then
local seen = {}
ensurePerson(people, child)
for _, row in ipairs(rows) do
addUnique(people, seen, row.Child)
end
 
if #people == 0 then
table.insert(people, person)
end


return people
if isRealValue(p1) then
end
ensurePerson(people, p1)
 
addUnique(people[child].parents, p1)
local function getRelationshipTypeForChild(person)
addUnique(people[p1].children, child)
person = trim(person)
if not person then
return nil
end
 
local rows = cargoQuery(
'ParentChild',
'RelationshipType',
{
where = 'Child="' .. esc(person) .. '"',
limit = 1
}
)
 
if rows[1] and trim(rows[1].RelationshipType) then
return trim(rows[1].RelationshipType)
end
 
return nil
end
 
local function buildCoupleGroups(people)
local groups = {}
local used = {}
local working = {}
 
for _, person in ipairs(people or {}) do
table.insert(working, person)
end
sorted(working)
 
for _, person in ipairs(working) do
if not used[person] then
local partners = getPartners(person)
local matchedPartner = nil
local matchedUnion = nil
 
for _, partner in ipairs(partners) do
for _, candidate in ipairs(working) do
if candidate == partner and not used[candidate] then
matchedPartner = partner
break
end
end
if matchedPartner then
local rows = cargoQuery(
'Unions',
'UnionID,Partner1,Partner2,UnionType,MarriageDate,EngagementDate',
{
where = '(Partner1="' .. esc(person) .. '" AND Partner2="' .. esc(matchedPartner) .. '") OR (Partner1="' .. esc(matchedPartner) .. '" AND Partner2="' .. esc(person) .. '")',
limit = 1
}
)
matchedUnion = rows[1]
break
end
end
end


if matchedPartner then
if isRealValue(p2) then
used[person] = true
ensurePerson(people, p2)
used[matchedPartner] = true
addUnique(people[child].parents, p2)
 
addUnique(people[p2].children, child)
local unionType = trim(matchedUnion and matchedUnion.UnionType) or 'Marriage'
local year = nil
if unionType == 'Engagement' then
year = formatYear(matchedUnion and matchedUnion.EngagementDate)
else
year = formatYear(matchedUnion and matchedUnion.MarriageDate)
end
 
table.insert(groups, {
type = 'couple',
left = person,
right = matchedPartner,
unionType = unionType,
marriageYear = year
})
else
used[person] = true
table.insert(groups, {
type = 'single',
person = person
})
end
end
end
end
end
end
return groups
end
end


local function buildFocusBranches(person)
local function loadUnions(people)
person = trim(person)
local results = cargo.query(
if not person then
'Unions2',
return {}
'UnionID,Partner1,Partner2,UnionType,Status,EngagementDate,MarriageDate,DivorceDate',
end
 
local branches = {}
 
local unions = cargoQuery(
'Unions',
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,EngagementDate',
{
{
where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
where = '(Partner1 IS NOT NULL OR Partner2 IS NOT NULL)',
limit = 100
limit = 5000
}
}
)
)


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


if p1 == person then
if isRealValue(p1) then ensurePerson(people, p1) end
partner = p2
if isRealValue(p2) then ensurePerson(people, p2) end
else
partner = p1
end


local childRows = cargoQuery(
if isRealValue(p1) and isRealValue(p2) then
'ParentChild',
addUnique(people[p1].partners, p2)
'Child,BirthOrder',
addUnique(people[p2].partners, p1)
{
where = 'UnionID="' .. esc(union.UnionID) .. '"',
limit = 100
}
)


table.sort(childRows, function(a, b)
table.insert(people[p1].unions, {
local aOrder = tonumber(a.BirthOrder) or 9999
unionID = unionID,
local bOrder = tonumber(b.BirthOrder) or 9999
partner = p2,
if aOrder == bOrder then
unionType = trim(row.UnionType),
return tostring(a.Child):lower() < tostring(b.Child):lower()
status = trim(row.Status),
end
engagementDate = trim(row.EngagementDate),
return aOrder < bOrder
marriageDate = trim(row.MarriageDate),
end)
divorceDate = trim(row.DivorceDate)
})


local children = {}
table.insert(people[p2].unions, {
for _, row in ipairs(childRows) do
unionID = unionID,
table.insert(children, row.Child)
partner = p1,
unionType = trim(row.UnionType),
status = trim(row.Status),
engagementDate = trim(row.EngagementDate),
marriageDate = trim(row.MarriageDate),
divorceDate = trim(row.DivorceDate)
})
end
end
end
end


local unionType = trim(union.UnionType) or 'Marriage'
local function loadData()
local year = nil
local people = queryCharacters()
if unionType == 'Engagement' then
loadParentChild(people)
year = formatYear(union.EngagementDate)
loadUnions(people)
else
year = formatYear(union.MarriageDate)
end


table.insert(branches, {
for _, person in pairs(people) do
branchPerson = partner,
person.parents = uniq(person.parents)
unionType = unionType,
person.children = uniq(person.children)
marriageYear = year,
person.partners = uniq(person.partners)
children = children
})
end
end


local orphanRows = cargoQuery(
return people
'ParentChild',
end
'Child,BirthOrder',
{
where = '(Parent1="' .. esc(person) .. '" OR Parent2="' .. esc(person) .. '") AND (UnionID="" OR UnionID IS NULL)',
limit = 100
}
)


if #orphanRows > 0 then
-- =========================================
table.sort(orphanRows, function(a, b)
-- Legacy crash stopper
local aOrder = tonumber(a.BirthOrder) or 9999
-- =========================================
local bOrder = tonumber(b.BirthOrder) or 9999
if aOrder == bOrder then
return tostring(a.Child):lower() < tostring(b.Child):lower()
end
return aOrder < bOrder
end)


local children = {}
-- Old code was still calling this. Keep it defined so the module doesn't explode.
for _, row in ipairs(orphanRows) do
local function makeFocusBranches()
table.insert(children, row.Child)
return ''
end
end


table.insert(branches, {
-- =========================================
branchPerson = person,
-- Rendering helpers
unionType = 'SoloParent',
-- =========================================
marriageYear = nil,
children = children
})
end


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


local function cardStyle()
local box = html.create('div')
return table.concat({
box:addClass('familytree-person')
'width:120px',
if isRealValue(extraClass) then
'min-height:56px',
box:addClass(extraClass)
'padding:8px 10px',
end
'border:1px solid #cab8aa',
'background:#fffdf9',
'border-radius:10px',
'box-shadow:0 2px 4px rgba(0,0,0,0.08)',
'text-align:center',
'box-sizing:border-box',
'font-size:0.92em',
'line-height:1.2',
'display:flex',
'flex-direction:column',
'justify-content:center'
}, ';')
end


local function yearsStyle()
box:wikitext(makeLink(name))
return 'margin-top:4px;font-size:0.74em;color:#7a6b60;'
return box
end
end


local function makeCard(pageName)
local function renderRow(title, peopleList, rowClass)
pageName = trim(pageName)
peopleList = safeArray(peopleList)
if not pageName then
if #peopleList == 0 then
return ''
return nil
end
end


local c = getCharacter(pageName)
local row = html.create('div')
local displayName = getDisplayName(pageName)
row:addClass('familytree-row')
local birthYear = c and formatYear(c.BirthDate) or nil
if isRealValue(rowClass) then
local deathYear = c and formatYear(c.DeathDate) or nil
row:addClass(rowClass)
end


local years = ''
if isRealValue(title) then
if birthYear or deathYear then
row:tag('div')
years = '<div style="' .. yearsStyle() .. '">' .. (birthYear or '?') .. '–' .. (deathYear or '') .. '</div>'
:addClass('familytree-row-label')
:wikitext(title)
end
end


return '<div style="' .. cardStyle() .. '">[[' .. pageName .. '|' .. displayName .. ']]' .. years .. '</div>'
local items = row:tag('div'):addClass('familytree-row-items')
end
 
local function makeRelationLabel(unionType, marriageYear)
local label = ''


if unionType == 'Marriage' and marriageYear then
for _, name in ipairs(peopleList) do
label = 'm. ' .. marriageYear
local box = renderPersonBox(name)
elseif unionType == 'Marriage' then
if box then
label = 'm.'
items:node(box)
elseif unionType == 'Engagement' and marriageYear then
label = 'eng. ' .. marriageYear
elseif unionType == 'Engagement' then
label = 'eng.'
elseif unionType == 'Affair' then
label = 'affair'
elseif unionType == 'Liaison' then
label = 'liaison'
elseif unionType == 'SoloParent' then
label = ''
elseif unionType and unionType ~= '' then
if marriageYear then
label = unionType .. ' ' .. marriageYear
else
label = unionType
end
end
end
end


return label
return row
end
end


local function makePartnerBranch(branchPerson, marriageYear, unionType)
local function sortNamesByDisplay(people, names)
local label = makeRelationLabel(unionType, marriageYear)
table.sort(names, function(a, b)
 
local ad = (people[a] and people[a].displayName) or a
local html = {}
local bd = (people[b] and people[b].displayName) or b
table.insert(html, '<div style="display:inline-flex;flex-direction:column;align-items:center;">')
return mw.ustring.lower(ad) < mw.ustring.lower(bd)
table.insert(html, makeCard(branchPerson))
end)
if label ~= '' then
table.insert(html, '<div style="font-size:0.7em;color:#7a6b60;margin-top:4px;white-space:nowrap;">' .. label .. '</div>')
end
table.insert(html, '</div>')
return table.concat(html)
end
end


local function makeCoupleRow(groups)
local function getGrandparents(people, personName)
if not groups or #groups == 0 then
local grandparents = {}
return ''
local person = people[personName]
end
if not person then return grandparents end


local html = {}
for _, parentName in ipairs(person.parents) do
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:22px;flex-wrap:wrap;width:100%;">')
local parent = people[parentName]
for _, group in ipairs(groups) do
if parent then
if group.type == 'single' then
for _, gp in ipairs(parent.parents) do
table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(group.person) .. '</div>')
addUnique(grandparents, gp)
else
local label = makeRelationLabel(group.unionType, group.marriageYear)
table.insert(html, '<div style="display:inline-flex;flex-direction:row;align-items:center;gap:8px;">')
table.insert(html, makeCard(group.left))
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
if label ~= '' then
table.insert(html, '<div style="font-size:0.7em;color:#7a6b60;margin-bottom:4px;white-space:nowrap;">' .. label .. '</div>')
end
end
table.insert(html, '<div style="width:40px;height:2px;background:#bdaea0;"></div>')
table.insert(html, '</div>')
table.insert(html, makeCard(group.right))
table.insert(html, '</div>')
end
end
end
end
table.insert(html, '</div>')
 
return table.concat(html)
return uniq(grandparents)
end
end


local function makeFocusBranches(branches)
local function getSiblings(people, personName)
if not branches or #branches == 0 then
local siblings = {}
return ''
local person = people[personName]
end
if not person then return siblings end
 
local html = {}
table.insert(html, '<div style="position:relative;width:100%;padding-top:24px;">')
table.insert(html, '<div style="position:absolute;top:10px;left:12%;right:12%;height:2px;background:#bdaea0;"></div>')
table.insert(html, '<div style="position:relative;z-index:1;display:flex;justify-content:center;align-items:flex-start;gap:24px;flex-wrap:wrap;width:100%;">')


for _, branch in ipairs(branches) do
local siblingSet = {}
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:8px;min-width:150px;flex:0 1 auto;">')
table.insert(html, '<div style="position:relative;display:inline-flex;justify-content:center;align-items:center;">')
table.insert(html, '<div style="position:absolute;top:-18px;left:50%;transform:translateX(-50%);width:2px;height:16px;background:#b8a79a;z-index:2;"></div>')
table.insert(html, makePartnerBranch(branch.branchPerson, branch.marriageYear, branch.unionType))
table.insert(html, '</div>')


if branch.children and #branch.children > 0 then
for _, parentName in ipairs(person.parents) do
table.insert(html, '<div style="width:2px;height:16px;background:#b8a79a;margin:0 auto;"></div>')
local parent = people[parentName]
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:8px;flex-wrap:wrap;max-width:260px;">')
if parent then
for _, child in ipairs(branch.children) do
for _, childName in ipairs(parent.children) do
local rel = getRelationshipTypeForChild(child)
if childName ~= personName then
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
siblingSet[childName] = true
table.insert(html, makeCard(child))
if rel and rel ~= 'Biological' then
table.insert(html, '<div style="font-size:0.68em;color:#8b7768;margin-top:4px;text-transform:lowercase;">' .. string.lower(rel) .. '</div>')
end
end
table.insert(html, '</div>')
end
end
table.insert(html, '</div>')
end
end
end


table.insert(html, '</div>')
for name, _ in pairs(siblingSet) do
table.insert(siblings, name)
end
end


table.insert(html, '</div>')
return uniq(siblings)
table.insert(html, '</div>')
return table.concat(html)
end
end


function p.connected(frame)
local function renderTreeForPerson(people, personName)
local args = frame.args
local person = people[personName]
local parentArgs = frame:getParent() and frame:getParent().args or {}
if not person then
local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])
return string.format(
 
'<strong>FamilyTree error:</strong> No character found for "%s".',
if not root then
personName or '(blank)'
return 'Error: no root provided. Use root=Character Name'
)
end
end


local visited = {}
local grandparents = getGrandparents(people, personName)
local queue = {}
local parents = uniq(person.parents)
local head = 1
local partners = uniq(person.partners)
local children = uniq(person.children)
local siblings = getSiblings(people, personName)


visited[root] = true
sortNamesByDisplay(people, grandparents)
table.insert(queue, root)
sortNamesByDisplay(people, parents)
sortNamesByDisplay(people, siblings)
sortNamesByDisplay(people, partners)
sortNamesByDisplay(people, children)


while head <= #queue do
local root = html.create('div')
local current = queue[head]
root:addClass('familytree-wrapper')
head = head + 1


local neighbors = {}
local gpRow = renderRow('Grandparents', grandparents, 'familytree-grandparents')
local parents = getParents(current)
if gpRow then root:node(gpRow) end
local children = getChildren(current)
local partners = getPartners(current)


for _, person in ipairs(parents) do
local pRow = renderRow('Parents', parents, 'familytree-parents')
addSet(neighbors, person)
if pRow then root:node(pRow) end
end
for _, person in ipairs(children) do
addSet(neighbors, person)
end
for _, person in ipairs(partners) do
addSet(neighbors, person)
end


for neighbor, _ in pairs(neighbors) do
local selfRow = html.create('div')
if neighbor and not visited[neighbor] then
selfRow:addClass('familytree-row')
visited[neighbor] = true
selfRow:addClass('familytree-focus-row')
table.insert(queue, neighbor)
end
end
end


local people = {}
selfRow:tag('div')
for name, _ in pairs(visited) do
:addClass('familytree-row-label')
table.insert(people, name)
:wikitext('Focus')
end
sorted(people)


local lines = {}
local selfItems = selfRow:tag('div'):addClass('familytree-row-items')
table.insert(lines, "'''Connected component for " .. getDisplayName(root) .. "'''")
selfItems:node(renderPersonBox(personName, 'familytree-focus-person'))
table.insert(lines, '* Total people found: ' .. tostring(#people))
root:node(selfRow)
for _, person in ipairs(people) do
table.insert(lines, '* ' .. makeLinkedName(person))
end


return table.concat(lines, '\n')
local sibRow = renderRow('Siblings', siblings, 'familytree-siblings')
end
if sibRow then root:node(sibRow) end


function p.profile(frame)
local partnerRow = renderRow('Partners', partners, 'familytree-partners')
local args = frame.args
if partnerRow then root:node(partnerRow) end
local parentArgs = frame:getParent() and frame:getParent().args or {}
local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])


if not root then
local childRow = renderRow('Children', children, 'familytree-children')
return 'Error: no root provided. Use root=Character Name'
if childRow then root:node(childRow) end
end


local parents = getParents(root)
return tostring(root)
local siblings = getSiblingGeneration(root)
end
local partners = getPartners(root)
local children = getChildren(root)


local siblingList = {}
local function renderProfileForPerson(people, personName)
for _, person in ipairs(siblings) do
local person = people[personName]
if person ~= root then
if not person then
table.insert(siblingList, person)
return string.format(
end
'<strong>FamilyTree error:</strong> No character found for "%s".',
personName or '(blank)'
)
end
end


local lines = {}
local root = html.create('div')
table.insert(lines, '{| class="wikitable" style="width:100%; max-width:900px;"')
root:addClass('familytree-profile')
table.insert(lines, '|-')
 
table.insert(lines, '! colspan="2" | Family profile for ' .. getDisplayName(root))
root:tag('div')
table.insert(lines, '|-')
:addClass('familytree-profile-name')
table.insert(lines, '! style="width:20%;" | Person')
:wikitext(makeLink(person.name))
table.insert(lines, '| ' .. makeLinkedName(root))
table.insert(lines, '|-')
table.insert(lines, '! Parents')
table.insert(lines, '| ' .. (#parents > 0 and linkList(parents) or '—'))
table.insert(lines, '|-')
table.insert(lines, '! Siblings')
table.insert(lines, '| ' .. (#siblingList > 0 and linkList(siblingList) or '—'))
table.insert(lines, '|-')
table.insert(lines, '! Partners')
table.insert(lines, '| ' .. (#partners > 0 and linkList(partners) or '—'))
table.insert(lines, '|-')
table.insert(lines, '! Children')
table.insert(lines, '| ' .. (#children > 0 and linkList(children) or '—'))
table.insert(lines, '|}')
return table.concat(lines)
end


function p.tree(frame)
local dl = root:tag('dl')
local args = frame.args
local parentArgs = frame:getParent() and frame:getParent().args or {}
local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])


if not root then
local function addField(label, value)
return 'Error: no root provided. Use root=Character Name'
if isRealValue(value) then
dl:tag('dt'):wikitext(label)
dl:tag('dd'):wikitext(value)
end
end
end


local parents = getParents(root)
addField('Display Name', person.displayName ~= person.name and person.displayName or nil)
local grandparents = {}
addField('Gender', person.gender)
local gpSeen = {}
addField('Birth Date', person.birthDate)
addField('Death Date', person.deathDate)
addField('Birth Family', person.birthFamily and makeLink(person.birthFamily) or nil)
addField('Current Family', person.currentFamily and makeLink(person.currentFamily) or nil)
addField('Status', person.status)


for _, parentName in ipairs(parents) do
if #person.parents > 0 then
local parentParents = getParents(parentName)
local parentLinks = {}
for _, gp in ipairs(parentParents) do
for _, name in ipairs(person.parents) do
addUnique(grandparents, gpSeen, gp)
table.insert(parentLinks, makeLink(name))
end
end
addField('Parents', table.concat(parentLinks, ', '))
end
end
sorted(grandparents)


local grandparentGroups = buildCoupleGroups(grandparents)
if #person.partners > 0 then
local parentGroups = buildCoupleGroups(parents)
local partnerLinks = {}
local branches = buildFocusBranches(root)
for _, name in ipairs(person.partners) do
table.insert(partnerLinks, makeLink(name))
end
addField('Partners', table.concat(partnerLinks, ', '))
end


local html = {}
if #person.children > 0 then
table.insert(html, '<div style="border:1px solid #cdbfb2;background:#f8f4ee;padding:28px 24px;margin:20px 0;border-radius:14px;text-align:center;">')
local childLinks = {}
table.insert(html, '<div style="text-align:center;font-weight:700;font-size:1.2em;margin-bottom:28px;color:#4e4036;">Family tree for ' .. getDisplayName(root) .. '</div>')
for _, name in ipairs(person.children) do
 
table.insert(childLinks, makeLink(name))
if #grandparentGroups > 0 then
end
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
addField('Children', table.concat(childLinks, ', '))
table.insert(html, makeCoupleRow(grandparentGroups))
table.insert(html, '</div>')
end
end


if #grandparentGroups > 0 and #parentGroups > 0 then
return tostring(root)
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
end
end


if #parentGroups > 0 then
-- =========================================
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
-- Public functions
table.insert(html, makeCoupleRow(parentGroups))
-- =========================================
table.insert(html, '</div>')
end


if #parentGroups > 0 then
function p.tree(frame)
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
local personName = getArg(frame, 'person') or mw.title.getCurrentTitle().text
end
local people = loadData()


table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
-- harmless legacy call so any older code path won't die
table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(root) .. '</div>')
makeFocusBranches()
table.insert(html, '</div>')


if #branches > 0 then
return renderTreeForPerson(people, personName)
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
end
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, makeFocusBranches(branches))
table.insert(html, '</div>')
end


table.insert(html, '</div>')
function p.profile(frame)
return table.concat(html)
local personName = getArg(frame, 'person') or mw.title.getCurrentTitle().text
local people = loadData()
return renderProfileForPerson(people, personName)
end
end


return p
return p

Revision as of 21:26, 29 March 2026

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 not s 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)
	if lowered == 'unknown' or lowered == 'none' or lowered == 'n/a' then
		return false
	end

	return true
end

local function safeArray(v)
	if type(v) == 'table' then
		return v
	end
	return {}
end

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

	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 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 getArg(frame, key)
	local v = frame.args[key]
	if isRealValue(v) then
		return trim(v)
	end

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

	return nil
end

-- =========================================
-- Cargo loaders
-- =========================================

local function queryCharacters()
	local results = cargo.query(
		'Characters2',
		'Name,DisplayName,Gender,BirthDate,DeathDate,BirthFamily,CurrentFamily,Status',
		{
			where = 'Name IS NOT NULL',
			limit = 5000
		}
	)

	local people = {}

	for _, row in ipairs(results) do
		local name = trim(row.Name)
		if isRealValue(name) then
			people[name] = {
				name = name,
				displayName = trim(row.DisplayName) or name,
				gender = trim(row.Gender),
				birthDate = trim(row.BirthDate),
				deathDate = trim(row.DeathDate),
				birthFamily = trim(row.BirthFamily),
				currentFamily = trim(row.CurrentFamily),
				status = trim(row.Status),
				parents = {},
				children = {},
				partners = {},
				unions = {}
			}
		end
	end

	return people
end

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

	if not people[name] then
		people[name] = {
			name = name,
			displayName = name,
			gender = nil,
			birthDate = nil,
			deathDate = nil,
			birthFamily = nil,
			currentFamily = nil,
			status = nil,
			parents = {},
			children = {},
			partners = {},
			unions = {}
		}
	end

	return people[name]
end

local function loadParentChild(people)
	local results = cargo.query(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{
			where = 'Child IS NOT NULL',
			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 loadUnions(people)
	local results = cargo.query(
		'Unions2',
		'UnionID,Partner1,Partner2,UnionType,Status,EngagementDate,MarriageDate,DivorceDate',
		{
			where = '(Partner1 IS NOT NULL OR Partner2 IS NOT NULL)',
			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),
				engagementDate = trim(row.EngagementDate),
				marriageDate = trim(row.MarriageDate),
				divorceDate = trim(row.DivorceDate)
			})

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

local function loadData()
	local people = queryCharacters()
	loadParentChild(people)
	loadUnions(people)

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

	return people
end

-- =========================================
-- Legacy crash stopper
-- =========================================

-- Old code was still calling this. Keep it defined so the module doesn't explode.
local function makeFocusBranches()
	return ''
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(title, peopleList, rowClass)
	peopleList = safeArray(peopleList)
	if #peopleList == 0 then
		return nil
	end

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

	if isRealValue(title) then
		row:tag('div')
			:addClass('familytree-row-label')
			:wikitext(title)
	end

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

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

	return row
end

local function sortNamesByDisplay(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 getGrandparents(people, personName)
	local grandparents = {}
	local person = people[personName]
	if not person then return grandparents end

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

	return uniq(grandparents)
end

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

	local siblingSet = {}

	for _, parentName in ipairs(person.parents) do
		local parent = people[parentName]
		if parent then
			for _, childName in ipairs(parent.children) do
				if childName ~= personName then
					siblingSet[childName] = true
				end
			end
		end
	end

	for name, _ in pairs(siblingSet) do
		table.insert(siblings, name)
	end

	return uniq(siblings)
end

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

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

	sortNamesByDisplay(people, grandparents)
	sortNamesByDisplay(people, parents)
	sortNamesByDisplay(people, siblings)
	sortNamesByDisplay(people, partners)
	sortNamesByDisplay(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')

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

	local sibRow = renderRow('Siblings', siblings, 'familytree-siblings')
	if sibRow then root:node(sibRow) 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 renderProfileForPerson(people, personName)
	local person = people[personName]
	if not person then
		return string.format(
			'<strong>FamilyTree error:</strong> No character found for "%s".',
			personName or '(blank)'
		)
	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('Gender', person.gender)
	addField('Birth Date', person.birthDate)
	addField('Death Date', person.deathDate)
	addField('Birth Family', person.birthFamily and makeLink(person.birthFamily) or nil)
	addField('Current Family', person.currentFamily and makeLink(person.currentFamily) or nil)
	addField('Status', person.status)

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

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

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

	return tostring(root)
end

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

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

	-- harmless legacy call so any older code path won't die
	makeFocusBranches()

	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

return p