Module:GetRels
From Bodhicitta
Documentation for this module may be created at Module:GetRels/doc
local p = {}
-- Configuration
local RELATIONSHIP_LIMIT = 500
local FILTER_THRESHOLD_RELS = 5
local FILTER_THRESHOLD_PAGES = 10
-- Relationship display name mappings
local REL_NAMES = {
["Contains a full translation of"] = {forward = 'Translation of', inverse = 'Full translations'},
["Contains a modern commentary of"] = {forward = 'Commentary of', inverse = 'Commentaries'},
["Contains a partial translation of"] = {forward = 'Partial translation of', inverse = 'Partial translations'},
["Contains a translated commentary of"] = {forward = 'Translated commentary of', inverse = 'Translated Commentaries'},
["Contains a recension of"] = {forward = 'Recension of', inverse = 'Recensions'},
["Contains a review of"] = {forward = 'Review of', inverse = 'Reviews'},
["Contains a teaching based on"] = {forward = 'Teaching based on', inverse = 'Teachings'},
["Contains a translation of"] = {forward = 'Translation of', inverse = 'Translations'},
["Contains polemical content related to"] = {forward = 'Polemical content on', inverse = 'Polemical content'},
["Contains scholarship related to"] = {forward = 'Scholarship on', inverse = 'Scholarship'},
["Has a title identical or similar to"] = {forward = 'Similar title', inverse = 'Similar title'},
["Has other volumes"] = {forward = 'Other volumes', inverse = 'Other volumes'},
["Is a book based on this dissertation"] = {forward = 'Based on dissertation', inverse = 'Based on dissertation'},
["Is a chapter or a part of"] = {forward = 'Chapter or part of', inverse = 'Contains chapter or part'},
["Is a commentary of"] = {forward = 'Commentary of', inverse = 'Commentaries'},
["Is a member of"] = {forward = 'Member of', inverse = 'Contains members'},
["Is a reading of"] = {forward = 'Reading of', inverse = 'Readings'},
["Is a scholarly presentation about"] = {forward = 'Scholarly presentation about', inverse = 'Scholarly presentations'},
["Is a teaching based on"] = {forward = 'Teaching on', inverse = 'Teachings'},
["Is an interview about"] = {forward = 'Interview about', inverse = 'Interviews'},
["Is another edition of"] = {forward = 'Other editions', inverse = 'Other editions'},
["Is related to"] = {forward = 'Related', inverse = 'Related'},
["Shares intertextuality with"] = {forward = 'Shares intertextuality', inverse = 'Shares intertextuality'}
}
-- Relationship directions (horizontal relationships are symmetric/peer-level)
-- All others are vertical (hierarchical/directional)
local HORIZONTAL_RELS = {
["Has other volumes"] = true,
["Has a title identical or similar to"] = true,
["Shares intertextuality with"] = true,
["Is another edition of"] = true
}
-- Relationship display priority (lower number = higher priority)
local REL_PRIORITY = {
["Contains a recension of"] = 1,
["Contains a full translation of"] = 2,
["Contains a translation of"] = 3,
["Is a commentary of"] = 4,
["Contains a modern commentary of"] = 5,
["Contains a translated commentary of"] = 6,
["Contains a partial translation of"] = 7,
["Is another edition of"] = 8,
["Has other volumes"] = 9,
["Has a title identical or similar to"] = 10,
["Is a reading of"] = 11,
["Is a book based on this dissertation"] = 12,
["Is a chapter or a part of"] = 13,
["Is a member of"] = 14,
["Is a teaching based on"] = 15,
["Contains a teaching based on"] = 16,
["Contains a review of"] = 17,
["Contains scholarship related to"] = 18,
["Is a scholarly presentation about"] = 19,
["Is an interview about"] = 20,
["Shares intertextuality with"] = 21,
["Is related to"] = 22,
["Contains polemical content related to"] = 23
}
-- Classification to namespace mapping
local CLASS_NAMESPACE = {
['Articles'] = 'Articles',
['Books'] = 'Books',
['Classical Works'] = 'Texts',
['Dissertations'] = 'Books',
['Multimedia'] = 'Media',
['Periodical Issues'] = 'Books',
['Sanskrit Publications'] = 'Books',
['Tibetan Publications'] = 'Books'
}
-- Helper function: Check if a relationship is horizontal (symmetric)
local function isHorizontalRelationship(rel)
return HORIZONTAL_RELS[rel] == true
end
-- Helper function: Fetch outgoing relationships for a page (page has property pointing to other pages)
-- Single query fetches all relationship properties at once instead of one query per property.
-- limit=1 restricts to 1 subject row (pageA itself); multi-valued properties are returned in full
-- regardless of the row limit, so RELATIONSHIP_LIMIT no longer applies here.
local function fetchOutgoingRelationships(pageA)
local queryArgs = { '[[' .. pageA .. ']]', limit = 1 }
for relName, _ in pairs(REL_NAMES) do
-- Use ?#- operator to get unlinked (plain text) values
table.insert(queryArgs, '?' .. relName .. '#-')
end
local query = mw.smw.ask(queryArgs)
if not query or not query[1] then return nil end
local results = {}
local row = query[1]
for relName, _ in pairs(REL_NAMES) do
local targets = row[relName]
if targets then
-- Handle both single value and table of values
if type(targets) == 'table' then
for _, target in ipairs(targets) do
table.insert(results, {Relationship = relName, RelatedTo = target})
end
else
table.insert(results, {Relationship = relName, RelatedTo = targets})
end
end
end
return #results > 0 and results or nil
end
-- Helper function: Fetch incoming relationships (other pages have property pointing to this page).
-- Also fetches Categories, Classification, Display title of, MediaSeries, and Volumenumber for
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink)
-- can use the cached data instead of issuing additional per-page queries.
local function fetchIncomingRelationships(pageA)
local results = {}
for relName, _ in pairs(REL_NAMES) do
local query = mw.smw.ask{
'[[' .. relName .. '::' .. pageA .. ']]',
'?#-=Page',
'?Classification=Classification',
'?Display title of=Title',
'?MediaSeries=MediaSeries',
'?Volumenumber=Volumenumber',
'?Class=Class',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?FullTitleTib=FullTitleTib',
'?DescriptionTib=DescriptionTib',
'?TileLabel=TileLabel',
limit = RELATIONSHIP_LIMIT
}
if query then
for _, row in ipairs(query) do
local sourcePage = row.Page
if sourcePage then
table.insert(results, {
Page = sourcePage,
Relationship = relName,
RelatedTo = pageA,
Classification = row.Classification,
Title = row.Title,
MediaSeries = row.MediaSeries,
Volumenumber = row.Volumenumber,
Class = row.Class,
TileDescription = row.TileDescription,
TileImageUrl = row.TileImageUrl,
FullTitleTib = row.FullTitleTib,
DescriptionTib = row.DescriptionTib,
TileLabel = row.TileLabel,
})
end
end
end
end
return #results > 0 and results or nil
end
-- Helper function: Build a lookup table of page metadata from the outgoing targets.
-- Fetches all fields needed for tile rendering plus library/series checks,
-- for all outgoing target pages in a single query, keyed by plain page name.
local function fetchOutgoingPageData(targets)
if not targets or #targets == 0 then return {} end
-- Build OR condition: [[PageA]]OR[[PageB]]OR...
local conditions = {}
for _, page in ipairs(targets) do
table.insert(conditions, '[[' .. page .. ']]')
end
local query = mw.smw.ask{
table.concat(conditions, 'OR'),
'?#-=Page',
'?Classification=Classification',
'?Display title of=Title',
'?MediaSeries=MediaSeries',
'?Volumenumber=Volumenumber',
'?Class=Class',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?FullTitleTib=FullTitleTib',
'?DescriptionTib=DescriptionTib',
'?TileLabel=TileLabel',
limit = #targets
}
local cache = {}
if query then
for _, row in ipairs(query) do
if row.Page then
cache[row.Page] = {
Classification = row.Classification,
Title = row.Title,
MediaSeries = row.MediaSeries,
Volumenumber = row.Volumenumber,
Class = row.Class,
TileDescription = row.TileDescription,
TileImageUrl = row.TileImageUrl,
FullTitleTib = row.FullTitleTib,
DescriptionTib = row.DescriptionTib,
TileLabel = row.TileLabel,
}
end
end
end
return cache
end
-- Helper function: Check if page is a Library Item using pre-fetched metadata.
-- A page with a Classification value is a Library Item on this wiki.
-- Falls back to a targeted SMW query only when Classification is absent, which handles
-- Classical Works stored under a Texts/ prefix or linked via the KeyTextPage property.
local function isLibraryItem(page, cachedData)
local classification = cachedData and cachedData.Classification
if type(classification) == 'table' then classification = classification[1] end
if classification and classification ~= '' then
return true
end
-- Fallback for pages not found in cache (Texts/ prefix or KeyTextPage variants)
local fallback = mw.smw.ask{
'[[Category:Library Items]][[Texts/' .. page .. ']]OR[[Category:Library Items]][[KeyTextPage::' .. page .. ']]',
limit = 1
}
return fallback ~= nil and #fallback > 0
end
-- Helper function: Check if page is a multi-volume series using pre-fetched data.
local function isMultiVolumeSeries(cachedData)
if not cachedData then return false end
local hasSeries = cachedData.MediaSeries and cachedData.MediaSeries ~= ''
local vol = cachedData.Volumenumber
local volNum = type(vol) == 'table' and tonumber(vol[1]) or tonumber(vol)
return hasSeries and volNum and volNum > 1
end
-- Helper function: Build page link with namespace using pre-fetched metadata.
local function buildPageLink(page, cachedData)
local classification = cachedData and cachedData.Classification
local title = cachedData and cachedData.Title
-- Classification may be a table if multi-valued (take first)
if type(classification) == 'table' then classification = classification[1] end
if type(title) == 'table' then title = title[1] end
local pagename = (title and title ~= '') and title or page
-- Remove RKTS identifiers
pagename = string.gsub(pagename, '%s?%(RKTS%w?%s?%d*%)', '')
local namespace = CLASS_NAMESPACE[classification] or classification or ''
if namespace ~= '' then
return namespace .. '/' .. pagename
end
return pagename
end
-- Helper function: Remove duplicates from indexed table
local function makeTableUnique(tbl)
local seen = {}
local result = {}
for _, value in ipairs(tbl) do
if not seen[value] then
seen[value] = true
table.insert(result, value)
end
end
return result
end
-- Helper function: Split string by delimiter
local function splitString(str, delimiter)
local result = {}
for match in string.gmatch(str, "([^" .. delimiter .. "]+)") do
table.insert(result, match)
end
return result
end
-- Helper function: Get display name for relationship
local function getRelationshipName(rel, direction)
if REL_NAMES[rel] then
return direction == 'forward' and REL_NAMES[rel].forward or REL_NAMES[rel].inverse
end
return rel
end
-- Helper function: Process outgoing relationships
local function processOutgoingRelationships(frame, data, allRels)
if not data then
return
end
for _, item in ipairs(data) do
local rel = item.Relationship
if not allRels[rel] then
allRels[rel] = {}
end
if allRels[rel].right then
allRels[rel].right = allRels[rel].right .. '~' .. item.RelatedTo
else
allRels[rel].right = item.RelatedTo
end
end
end
-- Helper function: Process incoming relationships.
-- pageCache receives pre-fetched metadata keyed by plain page name.
-- Incoming page metadata (fetched inline) is written into pageCache here so that
-- generateRelationshipSection can look it up by page name later.
local function processIncomingRelationships(frame, data, allRels, pageA, pageCache)
if not data then
return
end
for _, item in ipairs(data) do
local pageB = frame:preprocess('{{PAGENAME:' .. item.Page .. '}}')
local rel = item.Relationship
if not allRels[rel] then
allRels[rel] = {}
end
-- Build cachedData from inline fields; fall back to pageCache entry if sparse
local cachedData = {
Categories = item.Categories,
Classification = item.Classification,
Title = item.Title,
MediaSeries = item.MediaSeries,
Volumenumber = item.Volumenumber,
Class = item.Class,
TileDescription = item.TileDescription,
TileImageUrl = item.TileImageUrl,
FullTitleTib = item.FullTitleTib,
DescriptionTib = item.DescriptionTib,
TileLabel = item.TileLabel,
}
if not cachedData.Classification and pageCache[pageB] then
cachedData = pageCache[pageB]
end
-- Store in pageCache so generateRelationshipSection can find it by name
if not pageCache[pageB] then
pageCache[pageB] = cachedData
end
-- Skip if this is part of a multi-volume series (uses cached data, no extra query)
if not isMultiVolumeSeries(cachedData) then
if not isHorizontalRelationship(rel) then
if allRels[rel].left then
allRels[rel].left = allRels[rel].left .. '~' .. pageB
else
allRels[rel].left = pageB
end
elseif not allRels[rel].right then
-- Horizontal relationships are symmetric. Only use the incoming pass as a fallback
-- when the outgoing pass found nothing (i.e. the property is only set on page B side).
allRels[rel].right = pageB
end
end
end
end
-- Helper function: Check if filters are needed
local function shouldShowFilters(allRels)
local relCount = 0
for _ in pairs(allRels) do
relCount = relCount + 1
end
if relCount > FILTER_THRESHOLD_RELS then
return true
end
for _, dirs in pairs(allRels) do
for _, pages in pairs(dirs) do
local pageCount = 0
for _ in string.gmatch(pages, "([^~]+)") do
pageCount = pageCount + 1
end
if pageCount > FILTER_THRESHOLD_PAGES then
return true
end
end
end
return false
end
-- Helper function: Generate filter widgets
local function generateFilters(frame)
local filterRels = frame:callParserFunction{
name = '#widget',
args = {
'FilterRelationships',
id = 'Relationships',
placeholder = 'Filter by relationships...'
}
}
local filterPages = frame:callParserFunction{
name = '#widget',
args = {
'FilterRelationships',
id = 'Pages',
placeholder = 'Filter by pages found...'
}
}
return '<div class="row"><div class="col-sm-6">' .. filterRels .. '</div><div class="col-sm-6">' .. filterPages .. '</div></div>'
end
-- Helper function: Generate tile for existing page using pre-fetched cached data.
-- Calls RelationshipTile directly, bypassing the RelTileQuery #ask entirely.
local function generateTile(frame, page, cachedData)
local function firstVal(v)
if type(v) == 'table' then return v[1] or '' end
return v or ''
end
return frame:expandTemplate{
title = 'RelationshipTile',
args = {
[1] = page,
Title = firstVal(cachedData and cachedData.Title),
FullTitleTib = firstVal(cachedData and cachedData.FullTitleTib),
TileDescription = firstVal(cachedData and cachedData.TileDescription),
DescriptionTib = firstVal(cachedData and cachedData.DescriptionTib),
TileImageUrl = firstVal(cachedData and cachedData.TileImageUrl),
Class = firstVal(cachedData and cachedData.Class),
TileLabel = firstVal(cachedData and cachedData.TileLabel),
}
}
end
-- Helper function: Generate tile for non-existent page
local function generateNoExistTile(frame, page, cachedData)
local pageLink = buildPageLink(page, cachedData)
local parts = {
'<div class="col-md-6 mb-3 mb-md-4 filterable">',
'<div class="tsdwiki-pop-off tsdwiki-tile tsdwiki-tile-library">',
'<div class="tsdwiki-tile-library-content" style="flex: 0 0 100%; max-width: 100%;">',
'<div class="h4 mb-2 p-0 clipped-2-lines border-0 text-140 line13 carnelian">',
pageLink,
'</div>',
'<div class="text-muted clipped-3-lines unclamp-here carnelian">',
'<b>PAGE MISSING!</b> Please click to create this library item or the redirect that is missing to direct it to the appropriate page.',
'</div>',
'<div class="mt-1 mb-0 clipped-1-line carnelian font-italic text-right">',
'Click to create <i class="fa-sharp-duotone fa-solid fa-rectangle-history-circle-plus"></i>',
'</div></div>',
'{{NoExistTileLink|pageLink=' .. pageLink .. '}}',
'</div></div>',
'[[Category:Pages with red relationship tiles]]'
}
return frame:preprocess(table.concat(parts))
end
-- Helper function: Generate output for a relationship section.
-- pageCache is a lookup table of pre-fetched metadata keyed by plain page name.
local function generateRelationshipSection(frame, rel, dir, pages, pageCache)
-- Determine display name
local relName = getRelationshipName(rel, dir == 'left' and 'inverse' or 'forward')
-- Process pages, collecting only existing ones
local pagesList = makeTableUnique(splitString(pages, '~'))
local tiles = {}
for _, page in ipairs(pagesList) do
local cachedData = pageCache[page]
if isLibraryItem(page, cachedData) then
-- Page exists on this wiki — render tile directly from cached data, no extra query
table.insert(tiles, generateTile(frame, page, cachedData))
else
-- Page not found on this wiki — show red "PAGE MISSING" tile
table.insert(tiles, generateNoExistTile(frame, page, cachedData))
end
end
-- Hide entire section if no related pages exist on this wiki
if #tiles == 0 then
return ''
end
local output = {'<div class="filterableContent clearfix">'}
table.insert(output, '<h4 class="mx-3">' .. relName .. '</h4><div class="row mx-0">')
for _, tile in ipairs(tiles) do
table.insert(output, tile)
end
table.insert(output, '</div></div>')
return table.concat(output)
end
-- Main function
function p.main(frame)
-- Get current page name
local pageA = mw.title.getCurrentTitle().subpageText
-- Fetch relationships using local SMW queries
local outgoingRels = fetchOutgoingRelationships(pageA)
local incomingRels = fetchIncomingRelationships(pageA)
-- Return empty if no relationships found
if not outgoingRels and not incomingRels then
return ''
end
-- Collect all unique outgoing target page names so we can batch-fetch their metadata.
-- Incoming results already carry inline metadata from fetchIncomingRelationships.
local outgoingTargets = {}
local seenTargets = {}
if outgoingRels then
for _, item in ipairs(outgoingRels) do
if not seenTargets[item.RelatedTo] then
seenTargets[item.RelatedTo] = true
table.insert(outgoingTargets, item.RelatedTo)
end
end
end
-- One query for all outgoing targets: Categories, Classification, Display title, MediaSeries, Volumenumber
local pageCache = fetchOutgoingPageData(outgoingTargets)
-- Organize relationships (incoming metadata comes inline; outgoing uses pageCache)
local allRels = {}
processOutgoingRelationships(frame, outgoingRels, allRels)
processIncomingRelationships(frame, incomingRels, allRels, pageA, pageCache)
-- Build output
local output = {}
-- -- Add filters if needed
-- if shouldShowFilters(allRels) then
-- table.insert(output, generateFilters(frame))
-- end
-- Sort relationships by priority
local sortedRels = {}
for rel, _ in pairs(allRels) do
table.insert(sortedRels, rel)
end
table.sort(sortedRels, function(a, b)
local prioA = REL_PRIORITY[a] or 999
local prioB = REL_PRIORITY[b] or 999
return prioA < prioB
end)
-- Generate relationship sections in priority order (pageCache used for library checks)
for _, rel in ipairs(sortedRels) do
local dirs = allRels[rel]
for dir, pages in pairs(dirs) do
table.insert(output, generateRelationshipSection(frame, rel, dir, pages, pageCache))
end
end
return table.concat(output)
end
return p