Module:GetRels: Difference between revisions
From Bodhicitta
(VSCode edit) |
(VSCode edit) |
||
| (21 intermediate revisions by the same user not shown) | |||
| Line 5: | Line 5: | ||
local FILTER_THRESHOLD_RELS = 5 | local FILTER_THRESHOLD_RELS = 5 | ||
local FILTER_THRESHOLD_PAGES = 10 | 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 | -- Relationship display name mappings | ||
local REL_NAMES = { | local REL_NAMES = { | ||
["Contains a full translation of"] = {forward = ' | -- Not a RelatedTo relationship. Generated from the local TextTopics property | ||
["Contains a modern commentary of"] = {forward = ' | -- 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 partial translation of"] = {forward = 'Partial translation of', inverse = 'Partial translations'}, | ||
["Contains a translated commentary of"] = {forward = 'Translated commentary of', inverse = 'Translated Commentaries'}, | ["Contains a translated commentary of"] = {forward = 'Translated commentary of', inverse = 'Translated Commentaries'}, | ||
["Contains a recension of"] = {forward = 'Recension of', inverse = 'Recensions'}, | ["Contains a recension of"] = {forward = 'Recension of', inverse = 'Recensions'}, | ||
["Contains a review of"] = {forward = 'Review of', inverse = 'Reviews'}, | ["Contains a review of"] = {forward = 'Review of', inverse = 'Reviews'}, | ||
["Contains a teaching based on"] = {forward = 'Teaching based on', inverse = ' | ["Contains a teaching based on"] = {forward = 'Teaching based on', inverse = 'Works containing teachings'}, | ||
["Contains a translation of"] = {forward = 'Translation of', inverse = 'Translations'}, | ["Contains a translation of"] = {forward = 'Translation of', inverse = 'Translations'}, | ||
["Contains polemical content related to"] = {forward = 'Polemical content on', inverse = 'Polemical content'}, | ["Contains polemical content related to"] = {forward = 'Polemical content on', inverse = 'Polemical content'}, | ||
| Line 22: | Line 28: | ||
["Is a book based on this dissertation"] = {forward = 'Based on dissertation', inverse = 'Based on dissertation'}, | ["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 chapter or a part of"] = {forward = 'Chapter or part of', inverse = 'Contains chapter or part'}, | ||
["Is a commentary of"] = {forward = ' | ["Is a commentary of"] = {forward = 'Classical commentary of', inverse = 'Classical commentaries'}, | ||
["Is a member of"] = {forward = 'Member of', inverse = 'Contains members'}, | ["Is a member of"] = {forward = 'Member of', inverse = 'Contains members'}, | ||
["Is a reading of"] = {forward = 'Reading of', inverse = 'Readings'}, | ["Is a reading of"] = {forward = 'Reading of', inverse = 'Readings'}, | ||
["Is a scholarly presentation about"] = {forward = 'Scholarly presentation about', inverse = 'Scholarly presentations'}, | ["Is a scholarly presentation about"] = {forward = 'Scholarly presentation about', inverse = 'Scholarly presentations'}, | ||
["Is a teaching based on"] = {forward = 'Teaching on', inverse = 'Teachings'}, | ["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 an interview about"] = {forward = 'Interview about', inverse = 'Interviews'}, | ||
["Is another edition of"] = {forward = 'Other editions', inverse = 'Other editions'}, | ["Is another edition of"] = {forward = 'Other editions', inverse = 'Other editions'}, | ||
| Line 61: | Line 68: | ||
["Contains a teaching based on"] = 16, | ["Contains a teaching based on"] = 16, | ||
["Contains a review of"] = 17, | ["Contains a review of"] = 17, | ||
["Contains scholarship related to"] = | ["Is cited in"] = 18, | ||
["Is a scholarly presentation about"] = | ["Contains scholarship related to"] = 19, | ||
["Is an interview about"] = | ["Is a scholarly presentation about"] = 20, | ||
["Shares intertextuality with"] = | ["Is an interview about"] = 21, | ||
["Is related to"] = | ["Shares intertextuality with"] = 22, | ||
["Contains polemical content related to"] = | ["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 | |||
} | } | ||
| Line 120: | Line 132: | ||
-- Helper function: Fetch incoming relationships (other pages have property pointing to this page). | -- Helper function: Fetch incoming relationships (other pages have property pointing to this page). | ||
-- Also fetches | -- Also fetches Classification, Display title of, MediaSeries, Volumenumber, and tile fields for | ||
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink | -- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink, | ||
-- can use the cached data instead of issuing additional per-page queries. | -- generateTile) can use the cached data instead of issuing additional per-page queries. | ||
local function fetchIncomingRelationships(pageA) | local function fetchIncomingRelationships(pageA, pageFull) | ||
local results = {} | 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 | for relName, _ in pairs(REL_NAMES) do | ||
local query = mw.smw.ask{ | local query = mw.smw.ask{ | ||
'[[' .. relName .. '::' .. | '[[' .. relName .. '::' .. target .. ']]', | ||
'?#-=Page', | '?#-=Page', | ||
'?Classification=Classification', | |||
'?Display title of=Title', | '?Display title of=Title', | ||
'?MediaSeries=MediaSeries', | '?MediaSeries=MediaSeries', | ||
| Line 137: | Line 157: | ||
'?TileDescription=TileDescription', | '?TileDescription=TileDescription', | ||
'?TileImageUrl=TileImageUrl', | '?TileImageUrl=TileImageUrl', | ||
'?Citation=Citation', | |||
'?Pubdate=Pubdate', | |||
'?FrontCoverImg#=FrontCoverImg', | |||
'?BcaAuthor#=BcaAuthor', | |||
'?Description=Description', | |||
'?FullTitleTib=FullTitleTib', | '?FullTitleTib=FullTitleTib', | ||
'?DescriptionTib=DescriptionTib', | '?DescriptionTib=DescriptionTib', | ||
| Line 158: | Line 183: | ||
TileDescription = row.TileDescription, | TileDescription = row.TileDescription, | ||
TileImageUrl = row.TileImageUrl, | TileImageUrl = row.TileImageUrl, | ||
Citation = row.Citation, | |||
Pubdate = row.Pubdate, | |||
FrontCoverImg = row.FrontCoverImg, | |||
BcaAuthor = row.BcaAuthor, | |||
Description = row.Description, | |||
FullTitleTib = row.FullTitleTib, | FullTitleTib = row.FullTitleTib, | ||
DescriptionTib = row.DescriptionTib, | DescriptionTib = row.DescriptionTib, | ||
| Line 192: | Line 222: | ||
'?TileDescription=TileDescription', | '?TileDescription=TileDescription', | ||
'?TileImageUrl=TileImageUrl', | '?TileImageUrl=TileImageUrl', | ||
'?Citation=Citation', | |||
'?Pubdate=Pubdate', | |||
'?FrontCoverImg#=FrontCoverImg', | |||
'?BcaAuthor#=BcaAuthor', | |||
'?Description=Description', | |||
'?FullTitleTib=FullTitleTib', | '?FullTitleTib=FullTitleTib', | ||
'?DescriptionTib=DescriptionTib', | '?DescriptionTib=DescriptionTib', | ||
| Line 210: | Line 245: | ||
TileDescription = row.TileDescription, | TileDescription = row.TileDescription, | ||
TileImageUrl = row.TileImageUrl, | TileImageUrl = row.TileImageUrl, | ||
Citation = row.Citation, | |||
Pubdate = row.Pubdate, | |||
FrontCoverImg = row.FrontCoverImg, | |||
BcaAuthor = row.BcaAuthor, | |||
Description = row.Description, | |||
FullTitleTib = row.FullTitleTib, | FullTitleTib = row.FullTitleTib, | ||
DescriptionTib = row.DescriptionTib, | DescriptionTib = row.DescriptionTib, | ||
| Line 350: | Line 390: | ||
DescriptionTib = item.DescriptionTib, | DescriptionTib = item.DescriptionTib, | ||
TileLabel = item.TileLabel, | 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 | if not cachedData.Classification and pageCache[pageB] then | ||
| Line 434: | Line 482: | ||
end | 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{ | return frame:expandTemplate{ | ||
title = ' | title = 'TopicBiblioRow', | ||
args = { | args = { | ||
[1] | [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), | |||
} | } | ||
} | } | ||
| Line 476: | Line 532: | ||
-- Helper function: Generate output for a relationship section. | -- Helper function: Generate output for a relationship section. | ||
-- pageCache is a lookup table of pre-fetched metadata keyed by plain page name. | -- pageCache is a lookup table of pre-fetched metadata keyed by plain page name. | ||
local function generateRelationshipSection(frame, rel, dir, pages, pageCache) | -- 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 | -- Determine display name | ||
local relName = getRelationshipName(rel, dir == 'left' and 'inverse' or 'forward') | local relName = getRelationshipName(rel, dir == 'left' and 'inverse' or 'forward') | ||
| Line 489: | Line 690: | ||
-- Page exists on this wiki — render tile directly from cached data, no extra query | -- Page exists on this wiki — render tile directly from cached data, no extra query | ||
table.insert(tiles, generateTile(frame, page, cachedData)) | table.insert(tiles, generateTile(frame, page, cachedData)) | ||
else | -- 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 | ||
end | end | ||
| Line 500: | Line 714: | ||
end | end | ||
local output = {'<div class=" | -- Count comes from #tiles rather than #pagesList, so it reflects what is | ||
table.insert(output, '< | -- 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 | for _, tile in ipairs(tiles) do | ||
table.insert(output, tile) | table.insert(output, tile) | ||
| Line 512: | Line 735: | ||
-- Main function | -- Main function | ||
function p.main(frame) | function p.main(frame) | ||
-- Get current page name | -- 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 pageA = mw.title.getCurrentTitle().subpageText | ||
local pageFull = mw.title.getCurrentTitle().prefixedText | |||
-- Fetch relationships using local SMW queries | -- Fetch relationships using local SMW queries | ||
local outgoingRels = fetchOutgoingRelationships(pageA) | local outgoingRels = fetchOutgoingRelationships(pageA) | ||
local incomingRels = fetchIncomingRelationships(pageA) | local incomingRels = fetchIncomingRelationships(pageA, pageFull) | ||
local topicOfRels = fetchTopicOfRelationships(pageA) | |||
-- Return empty if | -- Return empty if nothing relates to this page at all | ||
if not outgoingRels and not incomingRels then | if not outgoingRels and not incomingRels and not topicOfRels then | ||
return '' | return '' | ||
end | end | ||
| Line 536: | Line 769: | ||
end | end | ||
end | end | ||
-- One query for all outgoing targets: | -- One query for all outgoing targets: Classification, Display title, MediaSeries, Volumenumber, tile fields | ||
local pageCache = fetchOutgoingPageData(outgoingTargets) | local pageCache = fetchOutgoingPageData(outgoingTargets) | ||
| Line 543: | Line 776: | ||
processOutgoingRelationships(frame, outgoingRels, allRels) | processOutgoingRelationships(frame, outgoingRels, allRels) | ||
processIncomingRelationships(frame, incomingRels, allRels, pageA, pageCache) | 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 | -- Build output | ||
| Line 563: | Line 799: | ||
end) | 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 | for _, rel in ipairs(sortedRels) do | ||
local dirs = allRels[rel] | local dirs = allRels[rel] | ||
for dir, pages in pairs(dirs) do | for dir, pages in pairs(dirs) do | ||
table.insert( | table.insert(sections, generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally)) | ||
end | end | ||
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) | return table.concat(output) | ||
Latest revision as of 13:31, 25 August 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),
}
}
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