Module:FamilyTree
From KB Lexicon
Documentation for this module may be created at Module:FamilyTree/doc
local p = {}
local cargo = mw.ext.cargo
local function esc(value)
if not value then
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)
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 formatYear(dateValue)
dateValue = trim(dateValue)
if not dateValue then
return nil
end
return tostring(dateValue):match('^(%d%d%d%d)')
end
local function addUnique(list, seen, value)
value = trim(value)
if value and not seen[value] then
seen[value] = true
table.insert(list, value)
end
end
local function addSet(set, value)
value = trim(value)
if value then
set[value] = true
end
end
local function sorted(list)
table.sort(list, function(a, b)
return tostring(a):lower() < tostring(b):lower()
end)
return list
end
local function getCharacter(pageName)
pageName = trim(pageName)
if not pageName then
return nil
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
return pageName
end
local function makeLinkedName(pageName)
return '[[' .. pageName .. '|' .. getDisplayName(pageName) .. ']]'
end
local function linkList(list)
local out = {}
for _, pageName in ipairs(list or {}) do
table.insert(out, makeLinkedName(pageName))
end
return table.concat(out, '<br>')
end
local function getParents(person)
person = trim(person)
if not person then
return {}, nil
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
return sorted(parents), rows[1]
end
local function getChildren(person)
person = trim(person)
if not person then
return {}
end
local rows = cargoQuery(
'ParentChild',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{
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
return aOrder < bOrder
end)
local children = {}
local seen = {}
for _, row in ipairs(rows) do
addUnique(children, seen, row.Child)
end
return children
end
local function getPartners(person)
person = trim(person)
if not person then
return {}, {}
end
local rows = cargoQuery(
'Unions',
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate',
{
where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
limit = 100
}
)
local partners = {}
local seen = {}
for _, row in ipairs(rows) do
local p1 = trim(row.Partner1)
local p2 = trim(row.Partner2)
if p1 == person then
addUnique(partners, seen, p2)
elseif p2 == person then
addUnique(partners, seen, p1)
end
end
return sorted(partners), rows
end
local function getSiblingGeneration(person)
person = trim(person)
if not person then
return {}
end
local targetRows = cargoQuery(
'ParentChild',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{
where = 'Child="' .. esc(person) .. '"',
limit = 10
}
)
if not targetRows[1] then
return { person }
end
local targetUnion = trim(targetRows[1].UnionID)
local targetP1 = trim(targetRows[1].Parent1)
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(
'ParentChild',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{
where = table.concat(whereParts, ' OR '),
limit = 100
}
)
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
return aOrder < bOrder
end)
local people = {}
local seen = {}
for _, row in ipairs(rows) do
addUnique(people, seen, row.Child)
end
if #people == 0 then
table.insert(people, person)
end
return people
end
local function getRelationshipTypeForChild(person)
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
if matchedPartner then
used[person] = true
used[matchedPartner] = true
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
return groups
end
local function buildFocusBranches(person)
person = trim(person)
if not person then
return {}
end
local branches = {}
local unions = cargoQuery(
'Unions',
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,EngagementDate',
{
where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
limit = 100
}
)
for _, union in ipairs(unions) do
local p1 = trim(union.Partner1)
local p2 = trim(union.Partner2)
local partner = nil
if p1 == person then
partner = p2
else
partner = p1
end
local childRows = cargoQuery(
'ParentChild',
'Child,BirthOrder',
{
where = 'UnionID="' .. esc(union.UnionID) .. '"',
limit = 100
}
)
table.sort(childRows, 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
return aOrder < bOrder
end)
local children = {}
for _, row in ipairs(childRows) do
table.insert(children, row.Child)
end
local unionType = trim(union.UnionType) or 'Marriage'
local year = nil
if unionType == 'Engagement' then
year = formatYear(union.EngagementDate)
else
year = formatYear(union.MarriageDate)
end
table.insert(branches, {
partner = partner,
unionType = unionType,
marriageYear = year,
children = children
})
end
local orphanRows = cargoQuery(
'ParentChild',
'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)
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 = {}
for _, row in ipairs(orphanRows) do
table.insert(children, row.Child)
end
table.insert(branches, {
partner = nil,
unionType = 'Illegitimate',
marriageYear = nil,
children = children
})
end
return branches
end
local function cardStyle()
return table.concat({
'width:120px',
'min-height:56px',
'padding:8px 10px',
'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()
return 'margin-top:4px;font-size:0.74em;color:#7a6b60;'
end
local function makeCard(pageName)
pageName = trim(pageName)
if not pageName then
return ''
end
local c = getCharacter(pageName)
local displayName = getDisplayName(pageName)
local birthYear = c and formatYear(c.BirthDate) or nil
local deathYear = c and formatYear(c.DeathDate) or nil
local years = ''
if birthYear or deathYear then
years = '<div style="' .. yearsStyle() .. '">' .. (birthYear or '?') .. '–' .. (deathYear or '') .. '</div>'
end
return '<div style="' .. cardStyle() .. '">[[' .. pageName .. '|' .. displayName .. ']]' .. years .. '</div>'
end
local function makeRelationLabel(unionType, marriageYear)
local label = ''
if unionType == 'Marriage' and marriageYear then
label = 'm. ' .. marriageYear
elseif unionType == 'Marriage' then
label = 'm.'
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 == 'Illegitimate' then
label = 'issue'
elseif unionType and unionType ~= '' then
if marriageYear then
label = unionType .. ' ' .. marriageYear
else
label = unionType
end
end
return label
end
local function makePartnerBranch(branchPerson, marriageYear, unionType)
local label = makeRelationLabel(unionType, marriageYear)
local html = {}
table.insert(html, '<div style="display:inline-flex;flex-direction:column;align-items:center;">')
table.insert(html, makeCard(branchPerson))
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
local function makeCoupleRow(groups)
if not groups or #groups == 0 then
return ''
end
local html = {}
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:22px;flex-wrap:wrap;width:100%;">')
for _, group in ipairs(groups) do
if group.type == 'single' then
table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(group.person) .. '</div>')
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
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
table.insert(html, '</div>')
return table.concat(html)
end
function p.connected(frame)
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
return 'Error: no root provided. Use root=Character Name'
end
local visited = {}
local queue = {}
local head = 1
visited[root] = true
table.insert(queue, root)
while head <= #queue do
local current = queue[head]
head = head + 1
local neighbors = {}
local parents = getParents(current)
local children = getChildren(current)
local partners = getPartners(current)
for _, person in ipairs(parents) do
addSet(neighbors, person)
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
if neighbor and not visited[neighbor] then
visited[neighbor] = true
table.insert(queue, neighbor)
end
end
end
local people = {}
for name, _ in pairs(visited) do
table.insert(people, name)
end
sorted(people)
local lines = {}
table.insert(lines, "'''Connected component for " .. getDisplayName(root) .. "'''")
table.insert(lines, '* Total people found: ' .. tostring(#people))
for _, person in ipairs(people) do
table.insert(lines, '* ' .. makeLinkedName(person))
end
return table.concat(lines, '\n')
end
function p.profile(frame)
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
return 'Error: no root provided. Use root=Character Name'
end
local parents = getParents(root)
local siblings = getSiblingGeneration(root)
local partners = getPartners(root)
local children = getChildren(root)
local siblingList = {}
for _, person in ipairs(siblings) do
if person ~= root then
table.insert(siblingList, person)
end
end
local lines = {}
table.insert(lines, '{| class="wikitable" style="width:100%; max-width:900px;"')
table.insert(lines, '|-')
table.insert(lines, '! colspan="2" | Family profile for ' .. getDisplayName(root))
table.insert(lines, '|-')
table.insert(lines, '! style="width:20%;" | Person')
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 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
return 'Error: no root provided. Use root=Character Name'
end
local parents = getParents(root)
local grandparents = {}
local gpSeen = {}
for _, parentName in ipairs(parents) do
local parentParents = getParents(parentName)
for _, gp in ipairs(parentParents) do
addUnique(grandparents, gpSeen, gp)
end
end
sorted(grandparents)
local grandparentGroups = buildCoupleGroups(grandparents)
local parentGroups = buildCoupleGroups(parents)
local branches = buildFocusBranches(root)
local html = {}
table.insert(html, '<div style="border:1px solid #cdbfb2;background:#f8f4ee;padding:28px 24px;margin:20px 0;border-radius:14px;text-align:center;">')
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>')
if #grandparentGroups > 0 then
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, makeCoupleRow(grandparentGroups))
table.insert(html, '</div>')
end
if #grandparentGroups > 0 and #parentGroups > 0 then
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
end
if #parentGroups > 0 then
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, makeCoupleRow(parentGroups))
table.insert(html, '</div>')
end
if #parentGroups > 0 then
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, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(root) .. '</div>')
table.insert(html, '</div>')
if #branches > 0 then
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
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>')
return table.concat(html)
end
return p