Module:GetRels: Difference between revisions

From Bodhicitta
(VSCode edit)
(VSCode edit)
Line 338: Line 338:
-- Helper function: Process incoming relationships.
-- Helper function: Process incoming relationships.
-- pageCache receives pre-fetched metadata keyed by plain page name.
-- 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)
local function processIncomingRelationships(frame, data, allRels, pageA, pageCache)
if not data then
if not data then
Line 351: Line 353:
end
end


-- Use data fetched inline with the incoming query; fall back to pageCache if needed
-- Build cachedData from inline fields; fall back to pageCache entry if sparse
local cachedData = {
local cachedData = {
Categories      = item.Categories,
Categories      = item.Categories,
Line 365: Line 367:
TileLabel      = item.TileLabel,
TileLabel      = item.TileLabel,
}
}
-- If inline data was sparse, check the pre-fetched cache
if not cachedData.Classification and pageCache[pageB] then
if not cachedData.Classification and pageCache[pageB] then
cachedData = pageCache[pageB]
cachedData = pageCache[pageB]
end
-- Store in pageCache so generateRelationshipSection can find it by name
if not pageCache[pageB] then
pageCache[pageB] = cachedData
end
end



Revision as of 17:20, 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',
			'?Categories=Categories',
			'?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,
						Categories     = row.Categories,
						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',
		'?Categories=Categories',
		'?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] = {
					Categories     = row.Categories,
					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 category data.
-- Falls back to a targeted SMW query only for Classical Works pages which may be stored
-- under a Texts/ prefix or linked via the KeyTextPage property.
local function isLibraryItem(page, cachedData, frame)
	-- Check pre-fetched category list first (covers the common direct-membership case)
	local cats = cachedData and cachedData.Categories
	if cats then
		if type(cats) == 'table' then
			for _, cat in ipairs(cats) do
				local catName = type(cat) == 'table' and (cat.fulltext or cat.title or tostring(cat)) or tostring(cat)
				if catName == 'Library Items' or catName == 'Category:Library Items' then
					return true
				end
			end
		elseif type(cats) == 'string' then
			if cats == 'Library Items' or cats == 'Category:Library Items' then
				return true
			end
		end
	end

	-- Fallback: check Texts/ prefix and KeyTextPage for pages not found directly
	-- (primarily Classical Works stored under a different title)
	local fallback = mw.smw.ask{
		'[[Category:Library Items]][[Texts/' .. page .. ']]OR[[Category:Library Items]][[KeyTextPage::' .. page .. ']]',
		limit = 1
	}
	return fallback ~= nil and #fallback > 0
end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

	for _, page in ipairs(pagesList) do
		local cachedData = pageCache[page]
		if isLibraryItem(page, cachedData, frame) 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