Module:GetRels: Difference between revisions

From Bodhicitta
((by SublimeText.Mediawiker))
(Pass FullTitleTib, DescriptionTib to the rows)
 
(362 intermediate revisions by the same user not shown)
Line 1: Line 1:
local p = {}
local p = {}


function p.main(frame)
-- Configuration
local RELATIONSHIP_LIMIT = 500
local FILTER_THRESHOLD_RELS = 5
local FILTER_THRESHOLD_PAGES = 10
-- Show the class pills only once there are more rows than this in total.
local FILTER_THRESHOLD_ROWS = 10
 
-- Relationship display name mappings
local REL_NAMES = {
-- Not a RelatedTo relationship. Generated from the local TextTopics property
-- by fetchTopicOfRelationships, and only ever in one direction, so both
-- labels are the same.
["Is a topic of"] = {forward = 'A topic of', inverse = 'A topic of'},
["Contains a full translation of"] = {forward = 'Full translation of', inverse = 'Full translations'},
["Contains a modern commentary of"] = {forward = 'Modern commentary of', inverse = 'Modern 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 = 'Works containing 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 = 'Classical commentary of', inverse = 'Classical 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 cited in"] = {forward = 'Cited in', inverse = 'Citations'},
["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,
["Is cited in"] = 18,
["Contains scholarship related to"] = 19,
["Is a scholarly presentation about"] = 20,
["Is an interview about"] = 21,
["Shares intertextuality with"] = 22,
["Is related to"] = 23,
["Contains polemical content related to"] = 24,
-- TextTopics is not one of the RelatedTo relationships; it is a separate
-- local property. It is listed last so it reads as broader context after the
-- specific bibliographic relationships above.
["Is a topic of"] = 25
}
 
-- 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 Classification, Display title of, MediaSeries, Volumenumber, and tile fields for
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink,
-- generateTile) can use the cached data instead of issuing additional per-page queries.
local function fetchIncomingRelationships(pageA, pageFull)
local results = {}
 
-- Match either the bare or the full title. Relationship properties store the
-- full title, but accepting both keeps this working for pages that are not
-- subpages, and for any entry recorded the other way round.
local target = pageA
if pageFull and pageFull ~= pageA then
target = pageA .. '||' .. pageFull
end
 
for relName, _ in pairs(REL_NAMES) do
local query = mw.smw.ask{
'[[' .. relName .. '::' .. target .. ']]',
'?#-=Page',
'?Classification=Classification',
'?Display title of=Title',
'?MediaSeries=MediaSeries',
'?Volumenumber=Volumenumber',
'?Class=Class',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?Citation=Citation',
'?Pubdate=Pubdate',
'?FrontCoverImg#=FrontCoverImg',
'?BcaAuthor#=BcaAuthor',
'?Description=Description',
'?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,
Citation    = row.Citation,
Pubdate      = row.Pubdate,
FrontCoverImg = row.FrontCoverImg,
BcaAuthor    = row.BcaAuthor,
Description  = row.Description,
FullTitleTib  = row.FullTitleTib,
DescriptionTib = row.DescriptionTib,
TileLabel      = row.TileLabel,
})
end
end
end
end
 
return #results > 0 and results or nil
end


-- pass in parent arguments from template
-- Helper function: Build a lookup table of page metadata from the outgoing targets.
local pArgs = frame:getParent().args
-- 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


-- get local pagename
-- Build OR condition: [[PageA]]OR[[PageB]]OR...
local pagename = mw.title.getCurrentTitle().subpageText
local conditions = {}
for _, page in ipairs(targets) do
table.insert(conditions, '[[' .. page .. ']]')
end


-- query for vertical relationships names
local query = mw.smw.ask{
local relsDirection = {}
table.concat(conditions, 'OR'),
local verticRelsQueryData = mw.ext.externalData.getExternalData{
'?#-=Page',
url = 'https://research.tsadra.org/index.php?title=Special%3AAsk&q=' .. mw.uri.encode( '[[-Has subobject::Property:Relationship]][[RelPropDir::vertical]]' ) .. '&po=RelPropName&p%5Blimit%5D=108&p%5Bformat%5D=csv',
'?Classification=Classification',
data = { RelPropName = 'RelPropName'},
'?Display title of=Title',
format = 'CSV with header'
'?MediaSeries=MediaSeries',
'?Volumenumber=Volumenumber',
'?Class=Class',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?Citation=Citation',
'?Pubdate=Pubdate',
'?FrontCoverImg#=FrontCoverImg',
'?BcaAuthor#=BcaAuthor',
'?Description=Description',
'?FullTitleTib=FullTitleTib',
'?DescriptionTib=DescriptionTib',
'?TileLabel=TileLabel',
limit = #targets
}
}
    for _, data in pairs( verticRelsQueryData ) do
    relsDirection[data.RelPropName] = 'vertical'
    end


-- query for horizontal relationships names
local cache = {}
local horizRelsQueryData = mw.ext.externalData.getExternalData{
if query then
url = 'https://research.tsadra.org/index.php?title=Special%3AAsk&q=' .. mw.uri.encode( '[[-Has subobject::Property:Relationship]][[RelPropDir::horizontal]]' ) .. '&po=RelPropName&p%5Blimit%5D=108&p%5Bformat%5D=csv',
for _, row in ipairs(query) do
data = { RelPropName = 'RelPropName'},
if row.Page then
format = 'CSV with header'
cache[row.Page] = {
Classification = row.Classification,
Title          = row.Title,
MediaSeries    = row.MediaSeries,
Volumenumber  = row.Volumenumber,
Class          = row.Class,
TileDescription = row.TileDescription,
TileImageUrl  = row.TileImageUrl,
Citation    = row.Citation,
Pubdate      = row.Pubdate,
FrontCoverImg = row.FrontCoverImg,
BcaAuthor    = row.BcaAuthor,
Description  = row.Description,
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
}
}
local horizRels = {}
return fallback ~= nil and #fallback > 0
    for _, data in pairs( horizRelsQueryData ) do
end
    relsDirection[data.RelPropName] = 'horizontal'
 
    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


-- build query string for main relationship subobject query and ASK query
local pagename = (title and title ~= '') and title or page
local queryResult = mw.ext.externalData.getExternalData{
-- Remove RKTS identifiers
url = 'https://research.tsadra.org/index.php?title=Special%3AAsk&q=' .. mw.uri.encode( '[[-Has subobject::' .. mw.title.getCurrentTitle().subpageText .. ']][[RelatedTo::+]][[RelationshipType::!ResourceToPerson]]OR[[RelatedTo::' .. mw.title.getCurrentTitle().subpageText .. ']][[RelationshipType::!ResourceToPerson]]' ) .. '&po=RelatedTo%0ARelationship&p%5Blimit%5D=10000&p%5Bmainlabel%5D=Page&p%5Bformat%5D=csv',
pagename = string.gsub(pagename, '%s?%(RKTS%w?%s?%d*%)', '')
data = {
 
Page = 'Page',
local namespace = CLASS_NAMESPACE[classification] or classification or ''
RelatedTo = 'RelatedTo',
if namespace ~= '' then
Relationship = 'Relationship'
return namespace .. '/' .. pagename
},
end
format = 'CSV with header'
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


-- define var for our main output
-- Helper function: Process outgoing relationships
local myResult = ''
local function processOutgoingRelationships(frame, data, allRels)
if not data then
return
end


-- check if query is empty first, if so, no need to go on
for _, item in ipairs(data) do
if queryResult == nil then return "No results!" end
local rel = item.Relationship


-- build our result string with intro element tag
if not allRels[rel] then
    myResult = myResult .. '<h1>Relationships</h1><table id="RelationshipsContent" class="smwtable-clean th-top-right th-nowrap border">'
allRels[rel] = {}
end


    -- check if more than 15 query results, if so, add filters to top of table
if allRels[rel].right then
if #queryResult > 15 then  
allRels[rel].right = allRels[rel].right .. '~' .. item.RelatedTo
local filterRels = frame:callParserFunction{
else
name = '#widget', args = {
allRels[rel].right = item.RelatedTo
'FilterRelationships',
end
id = 'Relationships',
end
placeholder = 'Filter by relationships...'
end
}
 
}
-- Helper function: Process incoming relationships.
local filterPages = frame:callParserFunction{
-- pageCache receives pre-fetched metadata keyed by plain page name.
name = '#widget', args = {
-- Incoming page metadata (fetched inline) is written into pageCache here so that
'FilterRelationships',
-- generateRelationshipSection can look it up by page name later.
id = 'Pages',
local function processIncomingRelationships(frame, data, allRels, pageA, pageCache)
placeholder = 'Filter by pages found...'
if not data then
}
return
}
myResult = myResult .. '<tr><td>' .. filterRels .. '</td><td></td><td>' .. filterPages .. '</td></tr>'
end
end


-- define table vars for storing different relationships
for _, item in ipairs(data) do
    local InRels = {}
local pageB = frame:preprocess('{{PAGENAME:' .. item.Page .. '}}')
    local OutRels = {}
local rel = item.Relationship
    local InferredRels = {}


    -- iterate through our query results
if not allRels[rel] then
    for num, data in ipairs( queryResult ) do
allRels[rel] = {}
end


    -- define a few pagename vars, page where subobject was found, encode both local and result pagenames so as to compare
-- Build cachedData from inline fields; fall back to pageCache entry if sparse
    local page = frame:preprocess( '{{PAGENAME:' .. data.Page .. '}}' )
local cachedData = {
    local pageEnc = mw.uri.anchorEncode( page )
Classification  = item.Classification,
    local pagenameEnc = mw.uri.anchorEncode( pagename )
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,
-- Row fields. This table is rebuilt field by field rather than
-- copied, so anything added to the queries must be listed here too
-- or it is silently dropped before it reaches the row.
Citation        = item.Citation,
Pubdate        = item.Pubdate,
FrontCoverImg  = item.FrontCoverImg,
BcaAuthor      = item.BcaAuthor,
Description    = item.Description,
}
if not cachedData.Classification and pageCache[pageB] then
cachedData = pageCache[pageB]
end


    -- checking if both local and result pagenames are the same, if so, we're talking about a relationship that is set locally, going out from this page
-- Store in pageCache so generateRelationshipSection can find it by name
    if pageEnc == pagenameEnc then
if not pageCache[pageB] then
pageCache[pageB] = cachedData
end


    -- defining variable that will hold the list of result pages for the given relationhsip
-- Skip if this is part of a multi-volume series (uses cached data, no extra query)
    local relatedToPages = ''
if not isMultiVolumeSeries(cachedData) then
    -- checking if only 1 result, if more, iterate and build the list
if not isHorizontalRelationship(rel) then
    if data.RelatedTo == nil then
if allRels[rel].left then
    relatedToPages = '<li class="filterable"><span style="color:red;">No item specified!</span></li>'
allRels[rel].left = allRels[rel].left .. '~' .. pageB
    else
    if type(data.RelatedTo) == "table" then
    for num, value in ipairs( data.RelatedTo ) do
    relatedToPages = relatedToPages .. '<li class="filterable">[[' .. value .. ']]</li>'
    end
else
else
relatedToPages = '<li class="filterable">[[' .. data.RelatedTo .. ']]</li>'
allRels[rel].left = pageB
end
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
end


-- let's populate the outgoing relationships table
-- Helper function: Check if filters are needed
    if OutRels[data.Relationship] then
local function shouldShowFilters(allRels)
    OutRels[data.Relationship] = OutRels[data.Relationship] .. relatedToPages
local relCount = 0
    else
for _ in pairs(allRels) do
    -- quick check to see if the relationship was set properly, if not, well... that's no good now is it!
relCount = relCount + 1
    if data.Relationship == nil then
end
    OutRels['<span style="color:red;">Relationship not set!</span>'] = relatedToPages
    else
if relCount > FILTER_THRESHOLD_RELS then
    OutRels[data.Relationship] = relatedToPages
return true
    end
end
    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


    else
-- 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


    -- for incoming relationship, let's do a quick check to see if it's a media page, and it's part of a bigger series, if so, let's only take the first track, otherwise there can be too many results
-- Helper function: Generate tile for existing page using pre-fetched cached data.
    local mediaCheckQuery = mw.ext.externalData.getExternalData{
-- Calls RelationshipTile directly, bypassing the RelTileQuery #ask entirely.
url = 'https://research.tsadra.org/index.php?title=Special%3AAsk&q=' .. mw.uri.encode( '[[' .. page .. ']][[MediaSeries::+]][[Volumenumber::>>1]]' ) .. '&p%5Bmainlabel%5D=wikipage&p%5Blimit%5D=108&p%5Bformat%5D=csv',
local function generateTile(frame, page, cachedData)
data = { wikipage = 'wikipage'},
local function firstVal(v)
format = 'CSV with header'
if type(v) == 'table' then return v[1] or '' end
}
return v or ''
end


if mediaCheckQuery == nil or tostring(#mediaCheckQuery) == '0' then
-- Rendered with TopicBiblioRow, the same row used on topic pages and for the
-- Explore section above, so a work looks the same wherever it is met.
--
-- The row falls back Citation -> Title -> page name, so entries with no
-- citation still read correctly, and it does its own reverse
-- AssociatedArticle lookup for narration. Nothing here needs to know about
-- either.
return frame:expandTemplate{
title = 'TopicBiblioRow',
args = {
[1]            = page,
Citation        = firstVal(cachedData and cachedData.Citation),
BcaAuthor      = firstVal(cachedData and cachedData.BcaAuthor),
Title          = firstVal(cachedData and cachedData.Title),
Class          = firstVal(cachedData and cachedData.Class),
Pubdate        = firstVal(cachedData and cachedData.Pubdate),
FrontCoverImg  = firstVal(cachedData and cachedData.FrontCoverImg),
TileDescription = firstVal(cachedData and cachedData.TileDescription),
Description    = firstVal(cachedData and cachedData.Description),
FullTitleTib    = firstVal(cachedData and cachedData.FullTitleTib),
DescriptionTib  = firstVal(cachedData and cachedData.DescriptionTib),
}
}
end


    -- if we're good to go, let's populate the incoming relationships table
-- Helper function: Generate tile for non-existent page
    -- let's first check if the relationship was actually set properly
local function generateNoExistTile(frame, page, cachedData)
    local rel = ''
local pageLink = buildPageLink(page, cachedData)
    if data.Relationship then
    rel = data.Relationship
local parts = {
    else
'<div class="col-md-6 mb-3 mb-md-4 filterable">',
    -- if not let's make it obvious
'<div class="tsdwiki-pop-off tsdwiki-tile tsdwiki-tile-library">',
    rel = '<span style="color: red; font-weight: bold;">Relationship not properly set!</span>'
'<div class="tsdwiki-tile-library-content" style="flex: 0 0 100%; max-width: 100%;">',
    end
'<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


    -- now let's get the actual display title of the page
-- Helper function: Generate output for a relationship section.
    local val = mw.ext.displaytitle.get(page)
-- pageCache is a lookup table of pre-fetched metadata keyed by plain page name.
-- Works that name this page as a topic, via the local TextTopics property.
--
-- This is not a RelatedTo relationship, so it is fetched separately, but the
-- rows carry the same printouts as fetchIncomingRelationships so they can be
-- rendered by the same tile code and appear as one more section.
local function fetchTopicOfRelationships(pageA)
local query = mw.smw.ask{
'[[TextTopics::' .. pageA .. ']]',
'?#-=Page',
'?Classification=Classification',
'?Display title of=Title',
'?MediaSeries=MediaSeries',
'?Volumenumber=Volumenumber',
'?Class=Class',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?Citation=Citation',
'?Pubdate=Pubdate',
'?FrontCoverImg#=FrontCoverImg',
'?BcaAuthor#=BcaAuthor',
'?Description=Description',
'?FullTitleTib=FullTitleTib',
'?DescriptionTib=DescriptionTib',
'?TileLabel=TileLabel',
limit = RELATIONSHIP_LIMIT
}


    -- and feed that title into the list of incoming relationships to be displayed on the page, for the proper relationship
if not query then
    if InRels[rel] then
return nil
    InRels[rel] = InRels[rel] .. '<li class="filterable">[[' .. page .. '|' .. val .. ']]</li>'
end
    else
    InRels[rel] = '<li class="filterable">[[' .. page .. '|' .. val .. ']]</li>'
    end


    -- Now find any further INFERRED relationships, let's check if the relationship is horizontal
local results = {}
    if relsDirection[rel] == 'horizontal' then
for _, row in ipairs(query) do
if row.Page then
table.insert(results, {
Page            = row.Page,
Relationship    = 'Is a topic of',
RelatedTo      = pageA,
Classification  = row.Classification,
Title          = row.Title,
MediaSeries    = row.MediaSeries,
Volumenumber    = row.Volumenumber,
Class          = row.Class,
TileDescription = row.TileDescription,
TileImageUrl    = row.TileImageUrl,
Citation    = row.Citation,
Pubdate      = row.Pubdate,
FrontCoverImg = row.FrontCoverImg,
BcaAuthor    = row.BcaAuthor,
Description  = row.Description,
FullTitleTib    = row.FullTitleTib,
DescriptionTib  = row.DescriptionTib,
TileLabel      = row.TileLabel,
})
end
end


    -- if it's horizontal, let's query more to figure out the inferred relationships of this property, via the page found. For example if this page (A) is another edition of the page found in the query above (B), let's query for any other page that might be other editions of page B, like C, D, E. Those would also be other editions of A.
if #results == 0 then
return nil
end
return results
end


    -- another ED query to find our results
-- Helper function: Build the controls shown above the sections — a text
local inferredQR = mw.ext.externalData.getExternalData{
-- filter and, when there is more than one class, the class pills.
url = 'https://research.tsadra.org/index.php?title=Special%3AAsk&q=' .. mw.uri.encode( '[[-Has subobject::!' .. pagename .. ']][[RelatedTo::' .. page .. ']][[Relationship::' .. rel .. ']]OR[[-Has subobject::' .. page .. ']][[Relationship::' .. rel .. ']]' ) .. '&po=RelatedTo&p%5Bmainlabel%5D=InferredPage&p%5Blimit%5D=10000&p%5Blink%5D=none&p%5Bformat%5D=csv',
--
data = { InferredPage = 'InferredPage', RelatedTo = 'RelatedTo' },
-- Same markup and same behaviour as the pills on topic pages: tws.js keys on
format = 'CSV with header'
-- .topic-biblio-pill and matches data-filter-class against each row's
}
-- data-class, so nothing extra is needed on the JS side.
myResult = myResult .. #inferredQR
--
-- Only worth showing when there is enough to filter. Below the threshold the
-- reader can already see everything, and a row of pills would be more clutter
-- than help.
local function generateControls(frame, classTally)
local total = classTally.__total or 0
if total <= FILTER_THRESHOLD_ROWS then
return ''
end


-- check if empty first
-- Ordered, not alphabetical: Editorial Content first, matching topic pages,
if inferredQR then
-- then the rest in a stable reading order. Anything not listed follows,
-- sorted, so a new Class still appears without a code change.
local ORDER = {'Editorial Content', 'Books', 'Articles', 'Classical Works',
'Multimedia', 'Dissertations', 'Tibetan Publications', 'Sanskrit Publications',
'Periodical Issues'}
local seen, ordered = {}, {}
for _, cls in ipairs(ORDER) do
if classTally[cls] then
table.insert(ordered, cls)
seen[cls] = true
end
end
local rest = {}
for cls in pairs(classTally) do
if cls ~= '__total' and not seen[cls] then
table.insert(rest, cls)
end
end
table.sort(rest)
for _, cls in ipairs(rest) do
table.insert(ordered, cls)
end
 
-- Fewer than two classes means the pills would be pointless — "All" and the
-- one class filter to the same set — but the text field is still worth
-- having, so only the pill row is dropped, not the whole control bar.
local showPills = #ordered >= 2
 
-- No newline before the first pill. MediaWiki wraps a run that starts on its
-- own line in a <p>, which then becomes the only flex child and collapses
-- the pill spacing. Same trap as on the topic page template.
local out = {'<div class="topic-biblio-controls">'}


-- if not, let's parse through our results
-- Text filter. The widget emits the same .filter-input the rest of the wiki
    for _, infData in ipairs( inferredQR ) do
-- uses, and tws.js already handles it: it matches accent-insensitively over
-- each .filterable row's text, highlights the hits, and re-applies the class
-- filter afterwards so the two controls compose instead of fighting.
-- Plain wrapper, not a Bootstrap .row: the grid's negative x-margins have no
-- container here to absorb them and push out a horizontal scrollbar. Width
-- and centring are done in CSS instead.
table.insert(out, '<div class="topic-biblio-filter">')
table.insert(out, frame:callParserFunction{
name = '#widget',
args = {
'filterInput',
placeholder = 'Filter ' .. total .. ' related works...'
}
})
table.insert(out, '</div>')


    -- from the results, let's see if our page B is the same as what's in the RelatedTo field of our results, if so, it means we're talking about a page C here
if showPills then
    if infData.RelatedTo == page then
table.insert(out, '<div class="topic-biblio-pills">')
table.insert(out, '<span class="topic-biblio-pill is-active" data-filter-class="">All <span class="rel-count">'
.. total .. '</span></span>')
for _, cls in ipairs(ordered) do
table.insert(out, '<span class="topic-biblio-pill" data-filter-class="' .. cls .. '">'
.. cls .. ' <span class="rel-count">' .. classTally[cls] .. '</span></span>')
end
table.insert(out, '</div>')
end


    -- let's strip the subobject hash from our page C pagename, we want the pagename that HOLDS the subobject, not the wiki name of the subobject itself.
table.insert(out, '</div>')
    local infPage = frame:preprocess( '{{PAGENAME:' .. infData.InferredPage .. '}}' )
return table.concat(out)
end
 
local function generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally)
-- 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))
-- Tally the class for the filter pills. Counted here, from what is
-- actually rendered, so a pill can never claim more than is shown.
-- A work can appear under several relationships, so this counts row
-- instances rather than distinct works — which is what the pills
-- filter over.
if classTally then
local cls = cachedData and cachedData.Class
if type(cls) == 'table' then cls = cls[1] end
if cls and cls ~= '' then
classTally[cls] = (classTally[cls] or 0) + 1
classTally.__total = (classTally.__total or 0) + 1
end
end
-- else
-- -- Page not found on this wiki — show red "PAGE MISSING" tile
-- table.insert(tiles, generateNoExistTile(frame, page, cachedData))
end
end


    -- let's get the displaytitle value for that page. If it's a classical work, it could be in the format RKTS### which isn't helpful. We want the actual title of the work.
-- Hide entire section if no related pages exist on this wiki
    local infVal = mw.ext.displaytitle.get(infPage)
if #tiles == 0 then
return ''
end


    -- let's feed those titles into the list of incoming relationships to be displayed on the page, for the proper horizontal inferred relationship
-- Count comes from #tiles rather than #pagesList, so it reflects what is
    if InRels[rel] then
-- actually rendered. Pages that do not exist on this wiki are filtered out
    InRels[rel] = InRels[rel] .. '<li class="filterable">[[' .. infPage .. '|' .. infVal .. ']]</li>'
-- above, and a heading claiming more items than are shown would be wrong.
    else
-- topic-biblio-section / -list, not the old Bootstrap grid: these are the
    InRels[rel] = '<li class="filterable">[[' .. infPage .. '|' .. infVal .. ']]</li>'
-- full-width rows used on topic pages, not two-column tiles. The grid's
    end
-- own "row" class would also collide with .topic-biblio-row.
-- No .filterableContent here: one wrapper spans every section (see main),
-- so the text filter reaches all rows rather than only the section it sits in.
local output = {'<div class="clearfix topic-biblio-section">'}
table.insert(output, '<div class="h4 mx-3"><span class="rel-count">' .. #tiles ..
'</span> ' .. relName .. '</div><div class="topic-biblio-list">')
for _, tile in ipairs(tiles) do
table.insert(output, tile)
end
table.insert(output, '</div></div>')


    -- if our page B is not what's in RelatedTo, then we're talking about other relationships defined on page B that we want to list, cause they're horizontal so apply to our page A.
return table.concat(output)
    -- let's check if RelatedTo is a table of value, it should since it's already holding the value for page A (that's how we found it in the first place, remember?), we're looking for those other values.
end
    elseif type(infData.RelatedTo) == "table" then


    -- let's parse through those values
-- Main function
    for _, value in ipairs( infData.RelatedTo ) do
function p.main(frame)
-- Get current page name.
--
-- Two forms are needed, because the two property families on this wiki store
-- their targets differently. The relationship properties store the FULL page
-- title (Texts/Bodhipathapradīpa) while TextTopics stores the BARE subpage
-- name (Bodhipathapradīpa). Querying only the bare form, as this module used
-- to, made every relationship section come back empty on a subpage while
-- "A topic of" still worked — which read as the topic section crowding the
-- others out, when in fact the others were never being found.
local pageA = mw.title.getCurrentTitle().subpageText
local pageFull = mw.title.getCurrentTitle().prefixedText


    -- let's check if one of these values is our page A, cause we obviously don't need to list that.
-- Fetch relationships using local SMW queries
    if value ~= pagename then
local outgoingRels = fetchOutgoingRelationships(pageA)
local incomingRels = fetchIncomingRelationships(pageA, pageFull)
local topicOfRels  = fetchTopicOfRelationships(pageA)


    -- find the displaytitle for our other values
-- Return empty if nothing relates to this page at all
    local infVal = mw.ext.displaytitle.get(value)
if not outgoingRels and not incomingRels and not topicOfRels then
return ''
end


    -- and feed those titles into the list of incoming relationships to be displayed on the page, for the proper horizontal inferred relationship
-- Collect all unique outgoing target page names so we can batch-fetch their metadata.
    if InRels[rel] then
-- Incoming results already carry inline metadata from fetchIncomingRelationships.
    InRels[rel] = InRels[rel] .. '<li class="filterable">[[' .. value .. ']]</li>'
local outgoingTargets = {}
    else
local seenTargets = {}
    InRels[rel] = '<li class="filterable">[[' .. value .. ']]</li>'
if outgoingRels then
    end
for _, item in ipairs(outgoingRels) do
    else
if not seenTargets[item.RelatedTo] then
    end
seenTargets[item.RelatedTo] = true
    end
table.insert(outgoingTargets, item.RelatedTo)
    end
    end
    end
end
end
end
    end
end
    end
end
-- One query for all outgoing targets: Classification, Display title, MediaSeries, Volumenumber, tile fields
local pageCache = fetchOutgoingPageData(outgoingTargets)


    -- now let's use the outgoing relationship table to build our table rows
-- Organize relationships (incoming metadata comes inline; outgoing uses pageCache)
    for rel, vals in pairs( OutRels ) do
local allRels = {}
    myResult = myResult .. '<tr><th>' .. rel .. '</th><td style="color: green;"><i class="fa-duotone fa-arrow-right"></i></td><td><ol style="margin: 0 1.3em;" class="sort-ascending">' .. vals .. '</ol></td></tr>'
processOutgoingRelationships(frame, outgoingRels, allRels)
processIncomingRelationships(frame, incomingRels, allRels, pageA, pageCache)
-- TextTopics rows carry the same fields as incoming rows, so they go through
-- the same processor and land in allRels as one more relationship.
processIncomingRelationships(frame, topicOfRels, 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
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)


    -- and the incoming relationship table to build our table rows
-- Sections are built first so the pills can be counted from what was
    for rel, vals in pairs( InRels ) do
-- actually rendered, then prepended. Counting up front would mean counting
myResult = myResult .. '<tr><th>' .. rel .. ' <div class="font-weight-normal font-italic">this work</div></th><td style="color: blue;"><i class="fa-duotone fa-arrow-left"></i></td><td><ol style="margin: 0 1.3em;" class="sort-ascending">' .. vals .. '</ol></td></tr>'
-- pages that get filtered out as non-existent further down.
local sections = {}
local classTally = {}
for _, rel in ipairs(sortedRels) do
local dirs = allRels[rel]
for dir, pages in pairs(dirs) do
table.insert(sections, generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally))
end
end
end


-- close our table tag and we're good to go!
table.insert(output, generateControls(frame, classTally))
    myResult = myResult .. '</table>'
-- One .filterableContent around every section, so the single text field
 
-- filters the whole relationships area rather than one section of it.
    return myResult
table.insert(output, '<div class="filterableContent">')
for _, section in ipairs(sections) do
table.insert(output, section)
end
table.insert(output, '</div>')


return table.concat(output)
end
end


return p
return p

Latest revision as of 12:01, 5 October 2026

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
-- Show the class pills only once there are more rows than this in total.
local FILTER_THRESHOLD_ROWS = 10

-- Relationship display name mappings
local REL_NAMES = {
	-- Not a RelatedTo relationship. Generated from the local TextTopics property
	-- by fetchTopicOfRelationships, and only ever in one direction, so both
	-- labels are the same.
	["Is a topic of"] = {forward = 'A topic of', inverse = 'A topic of'},
	["Contains a full translation of"] = {forward = 'Full translation of', inverse = 'Full translations'},
	["Contains a modern commentary of"] = {forward = 'Modern commentary of', inverse = 'Modern 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 = 'Works containing 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 = 'Classical commentary of', inverse = 'Classical 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 cited in"] = {forward = 'Cited in', inverse = 'Citations'},
	["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,
	["Is cited in"] = 18,
	["Contains scholarship related to"] = 19,
	["Is a scholarly presentation about"] = 20,
	["Is an interview about"] = 21,
	["Shares intertextuality with"] = 22,
	["Is related to"] = 23,
	["Contains polemical content related to"] = 24,
	-- TextTopics is not one of the RelatedTo relationships; it is a separate
	-- local property. It is listed last so it reads as broader context after the
	-- specific bibliographic relationships above.
	["Is a topic of"] = 25
}

-- 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 Classification, Display title of, MediaSeries, Volumenumber, and tile fields for
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink,
-- generateTile) can use the cached data instead of issuing additional per-page queries.
local function fetchIncomingRelationships(pageA, pageFull)
	local results = {}

	-- Match either the bare or the full title. Relationship properties store the
	-- full title, but accepting both keeps this working for pages that are not
	-- subpages, and for any entry recorded the other way round.
	local target = pageA
	if pageFull and pageFull ~= pageA then
		target = pageA .. '||' .. pageFull
	end

	for relName, _ in pairs(REL_NAMES) do
		local query = mw.smw.ask{
			'[[' .. relName .. '::' .. target .. ']]',
			'?#-=Page',
			'?Classification=Classification',
			'?Display title of=Title',
			'?MediaSeries=MediaSeries',
			'?Volumenumber=Volumenumber',
			'?Class=Class',
			'?TileDescription=TileDescription',
			'?TileImageUrl=TileImageUrl',
			'?Citation=Citation',
			'?Pubdate=Pubdate',
			'?FrontCoverImg#=FrontCoverImg',
			'?BcaAuthor#=BcaAuthor',
			'?Description=Description',
			'?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,
						Citation     = row.Citation,
						Pubdate      = row.Pubdate,
						FrontCoverImg = row.FrontCoverImg,
						BcaAuthor    = row.BcaAuthor,
						Description  = row.Description,
						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',
		'?Citation=Citation',
		'?Pubdate=Pubdate',
		'?FrontCoverImg#=FrontCoverImg',
		'?BcaAuthor#=BcaAuthor',
		'?Description=Description',
		'?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,
					Citation     = row.Citation,
					Pubdate      = row.Pubdate,
					FrontCoverImg = row.FrontCoverImg,
					BcaAuthor    = row.BcaAuthor,
					Description  = row.Description,
					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 = {
			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,
			-- Row fields. This table is rebuilt field by field rather than
			-- copied, so anything added to the queries must be listed here too
			-- or it is silently dropped before it reaches the row.
			Citation        = item.Citation,
			Pubdate         = item.Pubdate,
			FrontCoverImg   = item.FrontCoverImg,
			BcaAuthor       = item.BcaAuthor,
			Description     = item.Description,
		}
		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

	-- Rendered with TopicBiblioRow, the same row used on topic pages and for the
	-- Explore section above, so a work looks the same wherever it is met.
	--
	-- The row falls back Citation -> Title -> page name, so entries with no
	-- citation still read correctly, and it does its own reverse
	-- AssociatedArticle lookup for narration. Nothing here needs to know about
	-- either.
	return frame:expandTemplate{
		title = 'TopicBiblioRow',
		args = {
			[1]             = page,
			Citation        = firstVal(cachedData and cachedData.Citation),
			BcaAuthor       = firstVal(cachedData and cachedData.BcaAuthor),
			Title           = firstVal(cachedData and cachedData.Title),
			Class           = firstVal(cachedData and cachedData.Class),
			Pubdate         = firstVal(cachedData and cachedData.Pubdate),
			FrontCoverImg   = firstVal(cachedData and cachedData.FrontCoverImg),
			TileDescription = firstVal(cachedData and cachedData.TileDescription),
			Description     = firstVal(cachedData and cachedData.Description),
			FullTitleTib    = firstVal(cachedData and cachedData.FullTitleTib),
			DescriptionTib  = firstVal(cachedData and cachedData.DescriptionTib),
		}
	}
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.
-- Works that name this page as a topic, via the local TextTopics property.
--
-- This is not a RelatedTo relationship, so it is fetched separately, but the
-- rows carry the same printouts as fetchIncomingRelationships so they can be
-- rendered by the same tile code and appear as one more section.
local function fetchTopicOfRelationships(pageA)
	local query = mw.smw.ask{
		'[[TextTopics::' .. pageA .. ']]',
		'?#-=Page',
		'?Classification=Classification',
		'?Display title of=Title',
		'?MediaSeries=MediaSeries',
		'?Volumenumber=Volumenumber',
		'?Class=Class',
		'?TileDescription=TileDescription',
		'?TileImageUrl=TileImageUrl',
		'?Citation=Citation',
		'?Pubdate=Pubdate',
		'?FrontCoverImg#=FrontCoverImg',
		'?BcaAuthor#=BcaAuthor',
		'?Description=Description',
		'?FullTitleTib=FullTitleTib',
		'?DescriptionTib=DescriptionTib',
		'?TileLabel=TileLabel',
		limit = RELATIONSHIP_LIMIT
	}

	if not query then
		return nil
	end

	local results = {}
	for _, row in ipairs(query) do
		if row.Page then
			table.insert(results, {
				Page            = row.Page,
				Relationship    = 'Is a topic of',
				RelatedTo       = pageA,
				Classification  = row.Classification,
				Title           = row.Title,
				MediaSeries     = row.MediaSeries,
				Volumenumber    = row.Volumenumber,
				Class           = row.Class,
				TileDescription = row.TileDescription,
				TileImageUrl    = row.TileImageUrl,
				Citation     = row.Citation,
				Pubdate      = row.Pubdate,
				FrontCoverImg = row.FrontCoverImg,
				BcaAuthor    = row.BcaAuthor,
				Description  = row.Description,
				FullTitleTib    = row.FullTitleTib,
				DescriptionTib  = row.DescriptionTib,
				TileLabel       = row.TileLabel,
			})
		end
	end

	if #results == 0 then
		return nil
	end
	return results
end

-- Helper function: Build the controls shown above the sections — a text
-- filter and, when there is more than one class, the class pills.
--
-- Same markup and same behaviour as the pills on topic pages: tws.js keys on
-- .topic-biblio-pill and matches data-filter-class against each row's
-- data-class, so nothing extra is needed on the JS side.
--
-- Only worth showing when there is enough to filter. Below the threshold the
-- reader can already see everything, and a row of pills would be more clutter
-- than help.
local function generateControls(frame, classTally)
	local total = classTally.__total or 0
	if total <= FILTER_THRESHOLD_ROWS then
		return ''
	end

	-- Ordered, not alphabetical: Editorial Content first, matching topic pages,
	-- then the rest in a stable reading order. Anything not listed follows,
	-- sorted, so a new Class still appears without a code change.
	local ORDER = {'Editorial Content', 'Books', 'Articles', 'Classical Works',
		'Multimedia', 'Dissertations', 'Tibetan Publications', 'Sanskrit Publications',
		'Periodical Issues'}
	local seen, ordered = {}, {}
	for _, cls in ipairs(ORDER) do
		if classTally[cls] then
			table.insert(ordered, cls)
			seen[cls] = true
		end
	end
	local rest = {}
	for cls in pairs(classTally) do
		if cls ~= '__total' and not seen[cls] then
			table.insert(rest, cls)
		end
	end
	table.sort(rest)
	for _, cls in ipairs(rest) do
		table.insert(ordered, cls)
	end

	-- Fewer than two classes means the pills would be pointless — "All" and the
	-- one class filter to the same set — but the text field is still worth
	-- having, so only the pill row is dropped, not the whole control bar.
	local showPills = #ordered >= 2

	-- No newline before the first pill. MediaWiki wraps a run that starts on its
	-- own line in a <p>, which then becomes the only flex child and collapses
	-- the pill spacing. Same trap as on the topic page template.
	local out = {'<div class="topic-biblio-controls">'}

	-- Text filter. The widget emits the same .filter-input the rest of the wiki
	-- uses, and tws.js already handles it: it matches accent-insensitively over
	-- each .filterable row's text, highlights the hits, and re-applies the class
	-- filter afterwards so the two controls compose instead of fighting.
	-- Plain wrapper, not a Bootstrap .row: the grid's negative x-margins have no
	-- container here to absorb them and push out a horizontal scrollbar. Width
	-- and centring are done in CSS instead.
	table.insert(out, '<div class="topic-biblio-filter">')
	table.insert(out, frame:callParserFunction{
		name = '#widget',
		args = {
			'filterInput',
			placeholder = 'Filter ' .. total .. ' related works...'
		}
	})
	table.insert(out, '</div>')

	if showPills then
		table.insert(out, '<div class="topic-biblio-pills">')
		table.insert(out, '<span class="topic-biblio-pill is-active" data-filter-class="">All <span class="rel-count">'
			.. total .. '</span></span>')
		for _, cls in ipairs(ordered) do
			table.insert(out, '<span class="topic-biblio-pill" data-filter-class="' .. cls .. '">'
				.. cls .. ' <span class="rel-count">' .. classTally[cls] .. '</span></span>')
		end
		table.insert(out, '</div>')
	end

	table.insert(out, '</div>')
	return table.concat(out)
end

local function generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally)
	-- 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))
			-- Tally the class for the filter pills. Counted here, from what is
			-- actually rendered, so a pill can never claim more than is shown.
			-- A work can appear under several relationships, so this counts row
			-- instances rather than distinct works — which is what the pills
			-- filter over.
			if classTally then
				local cls = cachedData and cachedData.Class
				if type(cls) == 'table' then cls = cls[1] end
				if cls and cls ~= '' then
					classTally[cls] = (classTally[cls] or 0) + 1
					classTally.__total = (classTally.__total or 0) + 1
				end
			end
		-- 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

	-- Count comes from #tiles rather than #pagesList, so it reflects what is
	-- actually rendered. Pages that do not exist on this wiki are filtered out
	-- above, and a heading claiming more items than are shown would be wrong.
	-- topic-biblio-section / -list, not the old Bootstrap grid: these are the
	-- full-width rows used on topic pages, not two-column tiles. The grid's
	-- own "row" class would also collide with .topic-biblio-row.
	-- No .filterableContent here: one wrapper spans every section (see main),
	-- so the text filter reaches all rows rather than only the section it sits in.
	local output = {'<div class="clearfix topic-biblio-section">'}
	table.insert(output, '<div class="h4 mx-3"><span class="rel-count">' .. #tiles ..
		'</span> ' .. relName .. '</div><div class="topic-biblio-list">')
	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.
	--
	-- Two forms are needed, because the two property families on this wiki store
	-- their targets differently. The relationship properties store the FULL page
	-- title (Texts/Bodhipathapradīpa) while TextTopics stores the BARE subpage
	-- name (Bodhipathapradīpa). Querying only the bare form, as this module used
	-- to, made every relationship section come back empty on a subpage while
	-- "A topic of" still worked — which read as the topic section crowding the
	-- others out, when in fact the others were never being found.
	local pageA = mw.title.getCurrentTitle().subpageText
	local pageFull = mw.title.getCurrentTitle().prefixedText

	-- Fetch relationships using local SMW queries
	local outgoingRels = fetchOutgoingRelationships(pageA)
	local incomingRels = fetchIncomingRelationships(pageA, pageFull)
	local topicOfRels  = fetchTopicOfRelationships(pageA)

	-- Return empty if nothing relates to this page at all
	if not outgoingRels and not incomingRels and not topicOfRels 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: Classification, Display title, MediaSeries, Volumenumber, tile fields
	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)
	-- TextTopics rows carry the same fields as incoming rows, so they go through
	-- the same processor and land in allRels as one more relationship.
	processIncomingRelationships(frame, topicOfRels, 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)

	-- Sections are built first so the pills can be counted from what was
	-- actually rendered, then prepended. Counting up front would mean counting
	-- pages that get filtered out as non-existent further down.
	local sections = {}
	local classTally = {}
	for _, rel in ipairs(sortedRels) do
		local dirs = allRels[rel]
		for dir, pages in pairs(dirs) do
			table.insert(sections, generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally))
		end
	end

	table.insert(output, generateControls(frame, classTally))
	-- One .filterableContent around every section, so the single text field
	-- filters the whole relationships area rather than one section of it.
	table.insert(output, '<div class="filterableContent">')
	for _, section in ipairs(sections) do
		table.insert(output, section)
	end
	table.insert(output, '</div>')

	return table.concat(output)
end

return p