Module:GetRels: Difference between revisions

From Bodhicitta
(VSCode edit)
(VSCode edit)
Line 340: Line 340:
-- Build cachedData from inline fields; fall back to pageCache entry if sparse
-- Build cachedData from inline fields; fall back to pageCache entry if sparse
local cachedData = {
local cachedData = {
Categories      = item.Categories,
Classification  = item.Classification,
Classification  = item.Classification,
Title          = item.Title,
Title          = item.Title,

Revision as of 17:23, 25 March 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

-- Relationship display name mappings
local REL_NAMES = {
	["Contains a full translation of"] = {forward = 'Translation of', inverse = 'Full translations'},
	["Contains a modern commentary of"] = {forward = 'Commentary of', inverse = 'Commentaries'},
	["Contains a partial translation of"] = {forward = 'Partial translation of', inverse = 'Partial translations'},
	["Contains a translated commentary of"] = {forward = 'Translated commentary of', inverse = 'Translated Commentaries'},
	["Contains a recension of"] = {forward = 'Recension of', inverse = 'Recensions'},
	["Contains a review of"] = {forward = 'Review of', inverse = 'Reviews'},
	["Contains a teaching based on"] = {forward = 'Teaching based on', inverse = 'Teachings'},
	["Contains a translation of"] = {forward = 'Translation of', inverse = 'Translations'},
	["Contains polemical content related to"] = {forward = 'Polemical content on', inverse = 'Polemical content'},
	["Contains scholarship related to"] = {forward = 'Scholarship on', inverse = 'Scholarship'},
	["Has a title identical or similar to"] = {forward = 'Similar title', inverse = 'Similar title'},
	["Has other volumes"] = {forward = 'Other volumes', inverse = 'Other volumes'},
	["Is a book based on this dissertation"] = {forward = 'Based on dissertation', inverse = 'Based on dissertation'},
	["Is a chapter or a part of"] = {forward = 'Chapter or part of', inverse = 'Contains chapter or part'},
	["Is a commentary of"] = {forward = 'Commentary of', inverse = 'Commentaries'},
	["Is a member of"] = {forward = 'Member of', inverse = 'Contains members'},
	["Is a reading of"] = {forward = 'Reading of', inverse = 'Readings'},
	["Is a scholarly presentation about"] = {forward = 'Scholarly presentation about', inverse = 'Scholarly presentations'},
	["Is a teaching based on"] = {forward = 'Teaching on', inverse = 'Teachings'},
	["Is an interview about"] = {forward = 'Interview about', inverse = 'Interviews'},
	["Is another edition of"] = {forward = 'Other editions', inverse = 'Other editions'},
	["Is related to"] = {forward = 'Related', inverse = 'Related'},
	["Shares intertextuality with"] = {forward = 'Shares intertextuality', inverse = 'Shares intertextuality'}
}

-- Relationship directions (horizontal relationships are symmetric/peer-level)
-- All others are vertical (hierarchical/directional)
local HORIZONTAL_RELS = {
	["Has other volumes"] = true,
	["Has a title identical or similar to"] = true,
	["Shares intertextuality with"] = true,
	["Is another edition of"] = true
}

-- Relationship display priority (lower number = higher priority)
local REL_PRIORITY = {
	["Contains a recension of"] = 1,
	["Contains a full translation of"] = 2,
	["Contains a translation of"] = 3,
	["Is a commentary of"] = 4,
	["Contains a modern commentary of"] = 5,
	["Contains a translated commentary of"] = 6,
	["Contains a partial translation of"] = 7,
	["Is another edition of"] = 8,
	["Has other volumes"] = 9,
	["Has a title identical or similar to"] = 10,
	["Is a reading of"] = 11,
	["Is a book based on this dissertation"] = 12,
	["Is a chapter or a part of"] = 13,
	["Is a member of"] = 14,
	["Is a teaching based on"] = 15,
	["Contains a teaching based on"] = 16,
	["Contains a review of"] = 17,
	["Contains scholarship related to"] = 18,
	["Is a scholarly presentation about"] = 19,
	["Is an interview about"] = 20,
	["Shares intertextuality with"] = 21,
	["Is related to"] = 22,
	["Contains polemical content related to"] = 23
}

-- Classification to namespace mapping
local CLASS_NAMESPACE = {
	['Articles'] = 'Articles',
	['Books'] = 'Books',
	['Classical Works'] = 'Texts',
	['Dissertations'] = 'Books',
	['Multimedia'] = 'Media',
	['Periodical Issues'] = 'Books',
	['Sanskrit Publications'] = 'Books',
	['Tibetan Publications'] = 'Books'
}

-- Helper function: Check if a relationship is horizontal (symmetric)
local function isHorizontalRelationship(rel)
	return HORIZONTAL_RELS[rel] == true
end

-- Helper function: Fetch outgoing relationships for a page (page has property pointing to other pages)
-- Single query fetches all relationship properties at once instead of one query per property.
-- limit=1 restricts to 1 subject row (pageA itself); multi-valued properties are returned in full
-- regardless of the row limit, so RELATIONSHIP_LIMIT no longer applies here.
local function fetchOutgoingRelationships(pageA)
	local queryArgs = { '[[' .. pageA .. ']]', limit = 1 }
	for relName, _ in pairs(REL_NAMES) do
		-- Use ?#- operator to get unlinked (plain text) values
		table.insert(queryArgs, '?' .. relName .. '#-')
	end

	local query = mw.smw.ask(queryArgs)
	if not query or not query[1] then return nil end

	local results = {}
	local row = query[1]
	for relName, _ in pairs(REL_NAMES) do
		local targets = row[relName]
		if targets then
			-- Handle both single value and table of values
			if type(targets) == 'table' then
				for _, target in ipairs(targets) do
					table.insert(results, {Relationship = relName, RelatedTo = target})
				end
			else
				table.insert(results, {Relationship = relName, RelatedTo = targets})
			end
		end
	end

	return #results > 0 and results or nil
end

-- Helper function: Fetch incoming relationships (other pages have property pointing to this page).
-- Also fetches Categories, Classification, Display title of, MediaSeries, and Volumenumber for
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink)
-- can use the cached data instead of issuing additional per-page queries.
local function fetchIncomingRelationships(pageA)
	local results = {}

	for relName, _ in pairs(REL_NAMES) do
		local query = mw.smw.ask{
			'[[' .. relName .. '::' .. pageA .. ']]',
			'?#-=Page',
				'?Classification=Classification',
			'?Display title of=Title',
			'?MediaSeries=MediaSeries',
			'?Volumenumber=Volumenumber',
			'?Class=Class',
			'?TileDescription=TileDescription',
			'?TileImageUrl=TileImageUrl',
			'?FullTitleTib=FullTitleTib',
			'?DescriptionTib=DescriptionTib',
			'?TileLabel=TileLabel',
			limit = RELATIONSHIP_LIMIT
		}

		if query then
			for _, row in ipairs(query) do
				local sourcePage = row.Page
				if sourcePage then
					table.insert(results, {
						Page           = sourcePage,
						Relationship   = relName,
						RelatedTo      = pageA,
							Classification = row.Classification,
						Title          = row.Title,
						MediaSeries    = row.MediaSeries,
						Volumenumber   = row.Volumenumber,
						Class          = row.Class,
						TileDescription = row.TileDescription,
						TileImageUrl   = row.TileImageUrl,
						FullTitleTib   = row.FullTitleTib,
						DescriptionTib = row.DescriptionTib,
						TileLabel      = row.TileLabel,
					})
				end
			end
		end
	end

	return #results > 0 and results or nil
end

-- Helper function: Build a lookup table of page metadata from the outgoing targets.
-- Fetches all fields needed for tile rendering plus library/series checks,
-- for all outgoing target pages in a single query, keyed by plain page name.
local function fetchOutgoingPageData(targets)
	if not targets or #targets == 0 then return {} end

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

	local query = mw.smw.ask{
		table.concat(conditions, 'OR'),
		'?#-=Page',
		'?Classification=Classification',
		'?Display title of=Title',
		'?MediaSeries=MediaSeries',
		'?Volumenumber=Volumenumber',
		'?Class=Class',
		'?TileDescription=TileDescription',
		'?TileImageUrl=TileImageUrl',
		'?FullTitleTib=FullTitleTib',
		'?DescriptionTib=DescriptionTib',
		'?TileLabel=TileLabel',
		limit = #targets
	}

	local cache = {}
	if query then
		for _, row in ipairs(query) do
			if row.Page then
				cache[row.Page] = {
					Classification = row.Classification,
					Title          = row.Title,
					MediaSeries    = row.MediaSeries,
					Volumenumber   = row.Volumenumber,
					Class          = row.Class,
					TileDescription = row.TileDescription,
					TileImageUrl   = row.TileImageUrl,
					FullTitleTib   = row.FullTitleTib,
					DescriptionTib = row.DescriptionTib,
					TileLabel      = row.TileLabel,
				}
			end
		end
	end
	return cache
end

-- Helper function: Check if page is a Library Item using pre-fetched metadata.
-- A page with a Classification value is a Library Item on this wiki.
-- Falls back to a targeted SMW query only when Classification is absent, which handles
-- Classical Works stored under a Texts/ prefix or linked via the KeyTextPage property.
local function isLibraryItem(page, cachedData)
	local classification = cachedData and cachedData.Classification
	if type(classification) == 'table' then classification = classification[1] end
	if classification and classification ~= '' then
		return true
	end

	-- Fallback for pages not found in cache (Texts/ prefix or KeyTextPage variants)
	local fallback = mw.smw.ask{
		'[[Category:Library Items]][[Texts/' .. page .. ']]OR[[Category:Library Items]][[KeyTextPage::' .. page .. ']]',
		limit = 1
	}
	return fallback ~= nil and #fallback > 0
end

-- Helper function: Check if page is a multi-volume series using pre-fetched data.
local function isMultiVolumeSeries(cachedData)
	if not cachedData then return false end
	local hasSeries = cachedData.MediaSeries and cachedData.MediaSeries ~= ''
	local vol = cachedData.Volumenumber
	local volNum = type(vol) == 'table' and tonumber(vol[1]) or tonumber(vol)
	return hasSeries and volNum and volNum > 1
end

-- Helper function: Build page link with namespace using pre-fetched metadata.
local function buildPageLink(page, cachedData)
	local classification = cachedData and cachedData.Classification
	local title = cachedData and cachedData.Title

	-- Classification may be a table if multi-valued (take first)
	if type(classification) == 'table' then classification = classification[1] end
	if type(title) == 'table' then title = title[1] end

	local pagename = (title and title ~= '') and title or page
	-- Remove RKTS identifiers
	pagename = string.gsub(pagename, '%s?%(RKTS%w?%s?%d*%)', '')

	local namespace = CLASS_NAMESPACE[classification] or classification or ''
	if namespace ~= '' then
		return namespace .. '/' .. pagename
	end
	return pagename
end

-- Helper function: Remove duplicates from indexed table
local function makeTableUnique(tbl)
	local seen = {}
	local result = {}
	
	for _, value in ipairs(tbl) do
		if not seen[value] then
			seen[value] = true
			table.insert(result, value)
		end
	end
	
	return result
end

-- Helper function: Split string by delimiter
local function splitString(str, delimiter)
	local result = {}
	for match in string.gmatch(str, "([^" .. delimiter .. "]+)") do
		table.insert(result, match)
	end
	return result
end

-- Helper function: Get display name for relationship
local function getRelationshipName(rel, direction)
	if REL_NAMES[rel] then
		return direction == 'forward' and REL_NAMES[rel].forward or REL_NAMES[rel].inverse
	end
	return rel
end

-- Helper function: Process outgoing relationships
local function processOutgoingRelationships(frame, data, allRels)
	if not data then
		return
	end

	for _, item in ipairs(data) do
		local rel = item.Relationship

		if not allRels[rel] then
			allRels[rel] = {}
		end

		if allRels[rel].right then
			allRels[rel].right = allRels[rel].right .. '~' .. item.RelatedTo
		else
			allRels[rel].right = item.RelatedTo
		end
	end
end

-- Helper function: Process incoming relationships.
-- pageCache receives pre-fetched metadata keyed by plain page name.
-- Incoming page metadata (fetched inline) is written into pageCache here so that
-- generateRelationshipSection can look it up by page name later.
local function processIncomingRelationships(frame, data, allRels, pageA, pageCache)
	if not data then
		return
	end

	for _, item in ipairs(data) do
		local pageB = frame:preprocess('{{PAGENAME:' .. item.Page .. '}}')
		local rel = item.Relationship

		if not allRels[rel] then
			allRels[rel] = {}
		end

		-- Build cachedData from inline fields; fall back to pageCache entry if sparse
		local cachedData = {
			Classification  = item.Classification,
			Title           = item.Title,
			MediaSeries     = item.MediaSeries,
			Volumenumber    = item.Volumenumber,
			Class           = item.Class,
			TileDescription = item.TileDescription,
			TileImageUrl    = item.TileImageUrl,
			FullTitleTib    = item.FullTitleTib,
			DescriptionTib  = item.DescriptionTib,
			TileLabel       = item.TileLabel,
		}
		if not cachedData.Classification and pageCache[pageB] then
			cachedData = pageCache[pageB]
		end

		-- Store in pageCache so generateRelationshipSection can find it by name
		if not pageCache[pageB] then
			pageCache[pageB] = cachedData
		end

		-- Skip if this is part of a multi-volume series (uses cached data, no extra query)
		if not isMultiVolumeSeries(cachedData) then
			if not isHorizontalRelationship(rel) then
				if allRels[rel].left then
					allRels[rel].left = allRels[rel].left .. '~' .. pageB
				else
					allRels[rel].left = pageB
				end
			elseif not allRels[rel].right then
				-- Horizontal relationships are symmetric. Only use the incoming pass as a fallback
				-- when the outgoing pass found nothing (i.e. the property is only set on page B side).
				allRels[rel].right = pageB
			end
		end
	end
end

-- Helper function: Check if filters are needed
local function shouldShowFilters(allRels)
	local relCount = 0
	for _ in pairs(allRels) do
		relCount = relCount + 1
	end
	
	if relCount > FILTER_THRESHOLD_RELS then
		return true
	end
	
	for _, dirs in pairs(allRels) do
		for _, pages in pairs(dirs) do
			local pageCount = 0
			for _ in string.gmatch(pages, "([^~]+)") do
				pageCount = pageCount + 1
			end
			if pageCount > FILTER_THRESHOLD_PAGES then
				return true
			end
		end
	end
	
	return false
end

-- Helper function: Generate filter widgets
local function generateFilters(frame)
	local filterRels = frame:callParserFunction{
		name = '#widget',
		args = {
			'FilterRelationships',
			id = 'Relationships',
			placeholder = 'Filter by relationships...'
		}
	}
	
	local filterPages = frame:callParserFunction{
		name = '#widget',
		args = {
			'FilterRelationships',
			id = 'Pages',
			placeholder = 'Filter by pages found...'
		}
	}
	
	return '<div class="row"><div class="col-sm-6">' .. filterRels .. '</div><div class="col-sm-6">' .. filterPages .. '</div></div>'
end

-- Helper function: Generate tile for existing page using pre-fetched cached data.
-- Calls RelationshipTile directly, bypassing the RelTileQuery #ask entirely.
local function generateTile(frame, page, cachedData)
	local function firstVal(v)
		if type(v) == 'table' then return v[1] or '' end
		return v or ''
	end

	return frame:expandTemplate{
		title = 'RelationshipTile',
		args = {
			[1]              = page,
			Title            = firstVal(cachedData and cachedData.Title),
			FullTitleTib     = firstVal(cachedData and cachedData.FullTitleTib),
			TileDescription  = firstVal(cachedData and cachedData.TileDescription),
			DescriptionTib   = firstVal(cachedData and cachedData.DescriptionTib),
			TileImageUrl     = firstVal(cachedData and cachedData.TileImageUrl),
			Class            = firstVal(cachedData and cachedData.Class),
			TileLabel        = firstVal(cachedData and cachedData.TileLabel),
		}
	}
end

-- Helper function: Generate tile for non-existent page
local function generateNoExistTile(frame, page, cachedData)
	local pageLink = buildPageLink(page, cachedData)
	
	local parts = {
		'<div class="col-md-6 mb-3 mb-md-4 filterable">',
		'<div class="tsdwiki-pop-off tsdwiki-tile tsdwiki-tile-library">',
		'<div class="tsdwiki-tile-library-content" style="flex: 0 0 100%; max-width: 100%;">',
		'<div class="h4 mb-2 p-0 clipped-2-lines border-0 text-140 line13 carnelian">',
		pageLink,
		'</div>',
		'<div class="text-muted clipped-3-lines unclamp-here carnelian">',
		'<b>PAGE MISSING!</b> Please click to create this library item or the redirect that is missing to direct it to the appropriate page.',
		'</div>',
		'<div class="mt-1 mb-0 clipped-1-line carnelian font-italic text-right">',
		'Click to create <i class="fa-sharp-duotone fa-solid fa-rectangle-history-circle-plus"></i>',
		'</div></div>',
		'{{NoExistTileLink|pageLink=' .. pageLink .. '}}',
		'</div></div>',
		'[[Category:Pages with red relationship tiles]]'
	}
	
	return frame:preprocess(table.concat(parts))
end

-- Helper function: Generate output for a relationship section.
-- pageCache is a lookup table of pre-fetched metadata keyed by plain page name.
local function generateRelationshipSection(frame, rel, dir, pages, pageCache)
	-- Determine display name
	local relName = getRelationshipName(rel, dir == 'left' and 'inverse' or 'forward')

	-- Process pages, collecting only existing ones
	local pagesList = makeTableUnique(splitString(pages, '~'))
	local tiles = {}

	for _, page in ipairs(pagesList) do
		local cachedData = pageCache[page]
		if isLibraryItem(page, cachedData) then
			-- Page exists on this wiki — render tile directly from cached data, no extra query
			table.insert(tiles, generateTile(frame, page, cachedData))
		else
			-- Page not found on this wiki — show red "PAGE MISSING" tile
			table.insert(tiles, generateNoExistTile(frame, page, cachedData))
		end
	end

	-- Hide entire section if no related pages exist on this wiki
	if #tiles == 0 then
		return ''
	end

	local output = {'<div class="filterableContent clearfix">'}
	table.insert(output, '<h4 class="mx-3">' .. relName .. '</h4><div class="row mx-0">')
	for _, tile in ipairs(tiles) do
		table.insert(output, tile)
	end
	table.insert(output, '</div></div>')

	return table.concat(output)
end

-- Main function
function p.main(frame)
	-- Get current page name
	local pageA = mw.title.getCurrentTitle().subpageText

	-- Fetch relationships using local SMW queries
	local outgoingRels = fetchOutgoingRelationships(pageA)
	local incomingRels = fetchIncomingRelationships(pageA)

	-- Return empty if no relationships found
	if not outgoingRels and not incomingRels then
		return ''
	end

	-- Collect all unique outgoing target page names so we can batch-fetch their metadata.
	-- Incoming results already carry inline metadata from fetchIncomingRelationships.
	local outgoingTargets = {}
	local seenTargets = {}
	if outgoingRels then
		for _, item in ipairs(outgoingRels) do
			if not seenTargets[item.RelatedTo] then
				seenTargets[item.RelatedTo] = true
				table.insert(outgoingTargets, item.RelatedTo)
			end
		end
	end
	-- One query for all outgoing targets: Categories, Classification, Display title, MediaSeries, Volumenumber
	local pageCache = fetchOutgoingPageData(outgoingTargets)

	-- Organize relationships (incoming metadata comes inline; outgoing uses pageCache)
	local allRels = {}
	processOutgoingRelationships(frame, outgoingRels, allRels)
	processIncomingRelationships(frame, incomingRels, allRels, pageA, pageCache)

	-- Build output
	local output = {}

	-- -- Add filters if needed
	-- if shouldShowFilters(allRels) then
	-- 	table.insert(output, generateFilters(frame))
	-- end

	-- Sort relationships by priority
	local sortedRels = {}
	for rel, _ in pairs(allRels) do
		table.insert(sortedRels, rel)
	end
	table.sort(sortedRels, function(a, b)
		local prioA = REL_PRIORITY[a] or 999
		local prioB = REL_PRIORITY[b] or 999
		return prioA < prioB
	end)

	-- Generate relationship sections in priority order (pageCache used for library checks)
	for _, rel in ipairs(sortedRels) do
		local dirs = allRels[rel]
		for dir, pages in pairs(dirs) do
			table.insert(output, generateRelationshipSection(frame, rel, dir, pages, pageCache))
		end
	end

	return table.concat(output)
end

return p