Module:FamilyTree: Difference between revisions
From KB Lexicon
(Replaced content with "local p = {} function p.connected(frame) return 'CONNECTED OK' end function p.profile(frame) return 'PROFILE OK' end function p.tree(frame) local args = frame.args local parentArgs = frame:getParent() and frame:getParent().args or {} local root = args.root or parentArgs.root or args[1] or parentArgs[1] or 'NONE' return '<div style="padding:20px;border:4px solid red;font-size:20px;">FOCUS TREE LOADED FOR: ' .. tostring(root) .. '</div>' end return p") Tag: Replaced |
No edit summary |
||
| Line 1: | Line 1: | ||
local p = {} | 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 getUnionBetween(personA, personB) | |||
personA = trim(personA) | |||
personB = trim(personB) | |||
if not personA or not personB then | |||
return nil | |||
end | |||
local rows = cargoQuery( | |||
'Unions', | |||
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate', | |||
{ | |||
where = '(Partner1="' .. esc(personA) .. '" AND Partner2="' .. esc(personB) .. '") OR (Partner1="' .. esc(personB) .. '" AND Partner2="' .. esc(personA) .. '")', | |||
limit = 1 | |||
} | |||
) | |||
return rows[1] | |||
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, { | |||
person = person, | |||
partner = partner, | |||
unionType = unionType, | |||
marriageYear = year, | |||
children = children | |||
}) | |||
end | |||
local orphanRows = cargoQuery( | |||
'ParentChild', | |||
'Child,BirthOrder,RelationshipType', | |||
{ | |||
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, { | |||
person = person, | |||
partner = nil, | |||
unionType = 'Illegitimate', | |||
marriageYear = nil, | |||
children = children | |||
}) | |||
end | |||
local seenChildren = {} | |||
for _, branch in ipairs(branches) do | |||
for _, child in ipairs(branch.children or {}) do | |||
seenChildren[child] = true | |||
end | |||
end | |||
local directChildren = getChildren(person) | |||
local leftoverChildren = {} | |||
for _, child in ipairs(directChildren) do | |||
if not seenChildren[child] then | |||
table.insert(leftoverChildren, child) | |||
end | |||
end | |||
if #leftoverChildren > 0 then | |||
table.insert(branches, { | |||
person = person, | |||
partner = nil, | |||
unionType = 'Children', | |||
marriageYear = nil, | |||
children = leftoverChildren | |||
}) | |||
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 == 'Children' then | |||
label = 'children' | |||
elseif unionType and unionType ~= '' then | |||
if marriageYear then | |||
label = unionType .. ' ' .. marriageYear | |||
else | |||
label = unionType | |||
end | |||
end | |||
return label | |||
end | |||
local function makeCouple(left, right, marriageYear, unionType) | |||
local label = makeRelationLabel(unionType, marriageYear) | |||
if not right then | |||
local html = {} | |||
table.insert(html, '<div style="display:inline-flex;flex-direction:column;align-items:center;">') | |||
table.insert(html, makeCard(left)) | |||
if label ~= '' then | |||
table.insert(html, '<div style="font-size:0.7em;color:#a08c7d;margin-top:4px;">' .. label .. '</div>') | |||
end | |||
table.insert(html, '</div>') | |||
return table.concat(html) | |||
end | |||
local html = {} | |||
table.insert(html, '<div style="display:inline-flex;flex-direction:row;align-items:center;gap:8px;">') | |||
table.insert(html, makeCard(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(right)) | |||
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, makeCouple(group.person, nil, nil, nil)) | |||
else | |||
table.insert(html, makeCouple(group.left, group.right, group.marriageYear, group.unionType)) | |||
end | |||
end | |||
table.insert(html, '</div>') | |||
return table.concat(html) | |||
end | |||
local function makeFocusBranches(branches) | |||
if not branches or #branches == 0 then | |||
return '' | |||
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 | |||
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:flex-start;align-items:center;">') | |||
table.insert(html, '<div style="position:absolute;top:-18px;left:60px;width:2px;height:16px;background:#b8a79a;z-index:2;"></div>') | |||
table.insert(html, makeCouple(branch.person, branch.partner, branch.marriageYear, branch.unionType)) | |||
table.insert(html, '</div>') | |||
if branch.children and #branch.children > 0 then | |||
table.insert(html, '<div style="width:2px;height:16px;background:#b8a79a;margin:0 auto;"></div>') | |||
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:8px;flex-wrap:wrap;max-width:260px;">') | |||
for _, child in ipairs(branch.children) do | |||
local rel = getRelationshipTypeForChild(child) | |||
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">') | |||
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 | |||
table.insert(html, '</div>') | |||
end | |||
table.insert(html, '</div>') | |||
end | |||
table.insert(html, '</div>') | |||
end | |||
table.insert(html, '</div>') | |||
table.insert(html, '</div>') | |||
return table.concat(html) | |||
end | |||
function p.connected(frame) | function p.connected(frame) | ||
return ' | 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 | end | ||
function p.profile(frame) | function p.profile(frame) | ||
return ' | 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 | end | ||
| Line 12: | Line 701: | ||
local args = frame.args | local args = frame.args | ||
local parentArgs = frame:getParent() and frame:getParent().args or {} | local parentArgs = frame:getParent() and frame:getParent().args or {} | ||
local root = args.root or parentArgs.root or args[1] or parentArgs[1] | 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 | end | ||
return p | return p | ||
Revision as of 19:16, 29 March 2026
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 getUnionBetween(personA, personB)
personA = trim(personA)
personB = trim(personB)
if not personA or not personB then
return nil
end
local rows = cargoQuery(
'Unions',
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate',
{
where = '(Partner1="' .. esc(personA) .. '" AND Partner2="' .. esc(personB) .. '") OR (Partner1="' .. esc(personB) .. '" AND Partner2="' .. esc(personA) .. '")',
limit = 1
}
)
return rows[1]
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, {
person = person,
partner = partner,
unionType = unionType,
marriageYear = year,
children = children
})
end
local orphanRows = cargoQuery(
'ParentChild',
'Child,BirthOrder,RelationshipType',
{
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, {
person = person,
partner = nil,
unionType = 'Illegitimate',
marriageYear = nil,
children = children
})
end
local seenChildren = {}
for _, branch in ipairs(branches) do
for _, child in ipairs(branch.children or {}) do
seenChildren[child] = true
end
end
local directChildren = getChildren(person)
local leftoverChildren = {}
for _, child in ipairs(directChildren) do
if not seenChildren[child] then
table.insert(leftoverChildren, child)
end
end
if #leftoverChildren > 0 then
table.insert(branches, {
person = person,
partner = nil,
unionType = 'Children',
marriageYear = nil,
children = leftoverChildren
})
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 == 'Children' then
label = 'children'
elseif unionType and unionType ~= '' then
if marriageYear then
label = unionType .. ' ' .. marriageYear
else
label = unionType
end
end
return label
end
local function makeCouple(left, right, marriageYear, unionType)
local label = makeRelationLabel(unionType, marriageYear)
if not right then
local html = {}
table.insert(html, '<div style="display:inline-flex;flex-direction:column;align-items:center;">')
table.insert(html, makeCard(left))
if label ~= '' then
table.insert(html, '<div style="font-size:0.7em;color:#a08c7d;margin-top:4px;">' .. label .. '</div>')
end
table.insert(html, '</div>')
return table.concat(html)
end
local html = {}
table.insert(html, '<div style="display:inline-flex;flex-direction:row;align-items:center;gap:8px;">')
table.insert(html, makeCard(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(right))
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, makeCouple(group.person, nil, nil, nil))
else
table.insert(html, makeCouple(group.left, group.right, group.marriageYear, group.unionType))
end
end
table.insert(html, '</div>')
return table.concat(html)
end
local function makeFocusBranches(branches)
if not branches or #branches == 0 then
return ''
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
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:flex-start;align-items:center;">')
table.insert(html, '<div style="position:absolute;top:-18px;left:60px;width:2px;height:16px;background:#b8a79a;z-index:2;"></div>')
table.insert(html, makeCouple(branch.person, branch.partner, branch.marriageYear, branch.unionType))
table.insert(html, '</div>')
if branch.children and #branch.children > 0 then
table.insert(html, '<div style="width:2px;height:16px;background:#b8a79a;margin:0 auto;"></div>')
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:8px;flex-wrap:wrap;max-width:260px;">')
for _, child in ipairs(branch.children) do
local rel = getRelationshipTypeForChild(child)
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
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
table.insert(html, '</div>')
end
table.insert(html, '</div>')
end
table.insert(html, '</div>')
end
table.insert(html, '</div>')
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