Module:GetRels

From Bodhicitta
Revision as of 12:01, 5 October 2026 by Jeremi (talk | contribs) (Pass FullTitleTib, DescriptionTib to the rows)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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