Module:GetRels

From Bodhicitta
Revision as of 16:52, 11 March 2026 by Jeremi (talk | contribs) (VSCode edit)

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)
local function fetchOutgoingRelationships(pageA)
	local results = {}

	for relName, _ in pairs(REL_NAMES) do
		-- Use ?#- operator to get unlinked (plain text) values
		local query = mw.smw.ask{
			'[[' .. pageA .. ']][[' .. relName .. '::+]]',
			'?' .. relName .. '#-',
			limit = RELATIONSHIP_LIMIT
		}

		if query then
			for _, row in ipairs(query) 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
		end
	end

	return #results > 0 and results or nil
end

-- Helper function: Fetch incoming relationships (other pages have property pointing to this page)
local function fetchIncomingRelationships(pageA)
	local results = {}

	for relName, _ in pairs(REL_NAMES) do
		-- Use ?#- operator to get unlinked page names
		local query = mw.smw.ask{
			'[[' .. relName .. '::' .. pageA .. ']]',
			'?#-=Page',
			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})
				end
			end
		end
	end

	return #results > 0 and results or nil
end

-- Helper function: Check if page is a multi-volume series
local function isMultiVolumeSeries(page)
	local query = mw.smw.ask{
		'[[' .. page .. ']][[MediaSeries::+]][[Volumenumber::>>1]]',
		limit = 1
	}

	return query ~= nil
end

-- Helper function: Get volume number for a page
local function getVolumeNumber(page)
	local query = mw.smw.ask{
		'[[' .. page .. ']][[Volumenumber::+]]',
		'?Volumenumber',
		limit = 1
	}

	if query and query[1] then
		return query[1].Volumenumber
	end

	return nil
end

-- Helper function: Get page classification and display title
local function getPageMetadata(page)
	local query = mw.smw.ask{
		'[[' .. page .. ']][[Classification::+]]',
		'?Classification',
		'?Display title of=Title',
		limit = 1
	}

	if query and query[1] then
		return {
			classification = query[1].Classification,
			title = query[1].Title
		}
	end

	return nil
end

-- Helper function: Build page link with namespace
local function buildPageLink(page)
	local metadata = getPageMetadata(page)
	
	if not metadata then
		return page
	end
	
	local pagename = metadata.title
	if not pagename or pagename == '' then
		pagename = page
	else
		-- Remove RKTS identifiers
		pagename = string.gsub(pagename, '%s?%(RKTS%w?%s?%d*%)', '')
	end
	
	local namespace = CLASS_NAMESPACE[metadata.classification] or metadata.classification
	return namespace .. '/' .. 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
local function processIncomingRelationships(frame, data, allRels, pageA)
	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

		-- Skip if this is part of a multi-volume series
		local skipItem = isMultiVolumeSeries(pageB)

		if not skipItem 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
local function generateTile(frame, page)
	return frame:expandTemplate{
		title = 'RelTileQuery',
		args = {query = '[[Category:Library Items]][[' .. page .. ']]OR[[Category:Library Items]][[Texts/' .. page .. ']]OR[[Category:Library Items]][[KeyTextPage::' .. page .. ']]'}
	}
end

-- Helper function: Generate tile for non-existent page
local function generateNoExistTile(frame, page)
	local pageLink = buildPageLink(page)
	
	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
local function generateRelationshipSection(frame, rel, dir, pages)
	-- 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 tileCheck = mw.smw.ask('[[' .. page .. ']]')

		if tileCheck then
			table.insert(tiles, generateTile(frame, page))
		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

	-- Organize relationships
	local allRels = {}
	processOutgoingRelationships(frame, outgoingRels, allRels)
	processIncomingRelationships(frame, incomingRels, allRels, pageA)

	-- 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
	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))
		end
	end

	return table.concat(output)
end

return p