Module:PhysicsQC

From ScholarlyWiki
Revision as of 23:28, 20 May 2026 by Maintenance script (talk | contribs) (Use fresh source content for Quantum indexes)
Jump to navigation Jump to search

Module:PhysicsQC

This module renders a structured, collapsible table of contents for the Quantum Collection project.

It separates **content (Q)** from **logic (M)** and is invoked from a book or overview page (B).

Usage

Table of contents (198 articles)

Index

Full contents

9. Quantum optics and experiments (5) Back to index
14. Plasma and fusion physics (8) Back to index
Conceptual illustration of plasma physics in a fusion context, showing magnetically confined ionized gas in a tokamak and the collective behavior governed by electromagnetic fields and transport processes.
Conceptual illustration of plasma physics in a fusion context, showing magnetically confined ionized gas in a tokamak and the collective behavior governed by electromagnetic fields and transport processes.

Architecture

  • Q (source page) – contains structured content
  • M (this module) – processes, counts, and renders
  • B (book/page) – invokes the module

Source page (Q) format

The source page must contain:

<includeonly>

== Core pathway ==
# [[Physics:Quantum basics]]
# [[Physics:Quantum mechanics]]

== Full contents ==

=== Foundations ===
<li>[[Physics:Quantum basics]]</li>
<li>[[Physics:Quantum mechanics]]</li>

=== Conceptual and interpretations ===
<li>[[Physics:Quantum Interpretations of quantum mechanics]]</li>

=== Quantum optics and experiments ===
<li>[[Physics:Quantum optics beam splitter experiments]]</li>
[[Template:Quantum optics operators]]

</includeonly>

Features

  • Automatic total article count
  • Automatic per-section counts
  • Continuous numbering across sections
  • Independent collapsible sections
  • Clean rendering (no manual layout required)
  • No JavaScript required

Counting rules

Only items matching:

  • ... are counted. The following namespaces are excluded:
    • Template:
    • File:
    • Image:
    • Category:
    • Help:
    • Special:
    Duplicate links are counted only once in the total.

    Rendering behavior

    Each section:

    • is converted into a collapsible block
    • receives an automatic item count
    • is assigned correct numbering via <ol start="...">

    The module handles:

    • layout
    • numbering
    • counting

    The source page should NOT contain:

    • manual collapsible blocks
    • manual <ol> or start=
    • layout styling

    Important notes

    • Templates should NOT be inside <li> if you want numbering to match totals.
    • Images and descriptive text are preserved inside sections.
    • Section headings must use:
     === Section name ===
    

    Output structure

    The module returns:

    = Table of content (N articles)=
    
    == Core pathway ==
    ...
    
    == Full contents ==
    [collapsible sections]
    

    Maintenance

    To update content:

    • Edit the source page (Q)
    • Do NOT modify the module (M)

    The module automatically updates:

    • numbering
    • totals
    • layout

    Summary

    • Q = content and structure
    • M = logic and rendering
    • B = invocation

    This separation ensures scalability, consistency, and minimal maintenance.


  • local p = {}
    -- Cache bump: Use direct index content for fresh child-book images.
    -- Cache bump: Fields chapter expanded to 12 entries.
    
    -- ============================================================
    -- Module:PhysicsQC
    -- Quantum Collection table-of-contents and gallery renderer.
    --
    -- Restored rules:
    --   * Index main groups: 2 columns.
    --   * Book II / Book III / Book IV: one horizontal row below Index.
    --   * Full contents: ONE column.
    --   * Full contents preserves raw body, including images.
    --   * Counts exclude File/Image/Book/etc.
    --   * Gallery uses compact cards.
    -- ============================================================
    
    local function trim(s)
    	if not s then
    		return ''
    	end
    	return mw.text.trim(s)
    end
    
    local function escapeHtml(s)
    	return mw.text.encode(s or '')
    end
    
    local function getPageContent(pageName)
    	local title = mw.title.new(pageName or '')
    	if not title then
    		return ''
    	end
    
    	local text = title:getContent() or ''
    
    	-- Prevent visible <includeonly> / </includeonly> leaks from data/template pages.
    	text = text:gsub('<%s*/?%s*includeonly%s*>', '')
    
    	return text
    end
    
    local function getFramePageContent(frame, pageName)
    	if frame and frame.preprocess and pageName and pageName ~= '' then
    		local ok, text = pcall(function()
    			return frame:preprocess('{{:' .. pageName .. '}}')
    		end)
    
    		if ok and text and text ~= '' and text:match('==%s*Full contents%s*==') then
    			return text:gsub('<%s*/?%s*includeonly%s*>', '')
    		end
    	end
    
    	return getPageContent(pageName)
    end
    local function getCurrentPageName()
    	local t = mw.title.getCurrentTitle()
    	if not t then
    		return ''
    	end
    	return t.prefixedText or ''
    end
    
    local function isCurrentBookPage()
    	return getCurrentPageName():match('^Book:Quantum Collection') ~= nil
    end
    
    local function stripNamespaceForLabel(page)
    	page = page or ''
    	page = page:gsub('^Physics:Quantum data analysis/', '')
    	page = page:gsub('^Physics:Quantum methods/', '')
    	page = page:gsub('^Physics:Quantum materials/', '')
    	page = page:gsub('^Physics:Quantum matter/', '')
    	page = page:gsub('^Physics:Quantum atoms/', '')
    	page = page:gsub('^Physics:Quantum basics/', '')
    	page = page:gsub('^Physics:Quantum ', '')
    	page = page:gsub('^Physics:', '')
    	page = page:gsub('^Book:Quantum Collection/', '')
    	page = page:gsub('_', ' ')
    	return page
    end
    
    local function makeAnchorId(s)
    	s = s or ''
    	s = mw.text.decode(s, true)
    	s = s:gsub('<[^>]+>', '')
    	s = s:gsub('&nbsp;', ' ')
    	s = trim(s)
    	s = s:gsub('%s+', '_')
    	return mw.uri.anchorEncode(s)
    end
    
    local function isSkippedLink(link)
    	if not link or link == '' then
    		return true
    	end
    
    	link = trim(link)
    
    	if link:match('^#') then return true end
    	if link:match('^File:') then return true end
    	if link:match('^Image:') then return true end
    	if link:match('^Category:') then return true end
    	if link:match('^Help:') then return true end
    	if link:match('^Special:') then return true end
    	if link:match('^Template:') then return true end
    	if link:match('^Module:') then return true end
    
    	return false
    end
    
    local function isSkippedArticleCountLink(link)
    	if isSkippedLink(link) then return true end
    	if link:match('^Book:') then return true end
    	return false
    end
    
    local function parseWikiLink(raw)
    	raw = raw or ''
    
    	local target, label = raw:match('^([^|]+)|(.+)$')
    
    	if not target then
    		target = raw
    		label = stripNamespaceForLabel(raw)
    	end
    
    	target = trim(target)
    	label = trim(label)
    
    	return target, label
    end
    
    local function countArticleLinks(text)
    	local count = 0
    	local seen = {}
    
    	for raw in (text or ''):gmatch('%[%[([^%]]+)%]%]') do
    		local target = raw:match('^([^|#]+)') or raw
    		target = trim(target)
    
    		if not isSkippedArticleCountLink(target) and not seen[target] then
    			seen[target] = true
    			count = count + 1
    		end
    	end
    
    	return count
    end
    
    -- ============================================================
    -- Parse == Index ==
    -- ============================================================
    
    local function parseIndex(content)
    	local indexText = content:match('==%s*Index%s*==%s*(.-)%s*==%s*Full contents%s*==')
    
    	if not indexText then
    		indexText = content:match('==%s*Index%s*==%s*(.*)') or ''
    	end
    
    	local groups = {}
    	local currentGroup = nil
    
    	for line in indexText:gmatch('[^\r\n]+') do
    		line = trim(line)
    
    		if line ~= '' then
    			local boldContent = line:match("^'''%s*(.-)%s*'''%s*$")
    
    			if boldContent then
    				currentGroup = {
    					name = trim(boldContent),
    					links = {}
    				}
    				table.insert(groups, currentGroup)
    			else
    				for raw in line:gmatch('%[%[([^%]]+)%]%]') do
    					local target, label = parseWikiLink(raw)
    					target = target:match('^([^#]+)') or target
    					target = trim(target)
    
    					if currentGroup and not isSkippedLink(target) then
    						table.insert(currentGroup.links, {
    							target = target,
    							label = label
    						})
    					end
    				end
    			end
    		end
    	end
    
    	return groups
    end
    
    local function isBookGroup(group)
    	if not group or not group.name then
    		return false
    	end
    
    	local name = group.name
    
    	if name == 'Book II' then return true end
    	if name == 'Book III' then return true end
    	if name == 'Book IV' then return true end
    
    	if name == 'Quantum Book II' then return true end
    	if name == 'Quantum Book III' then return true end
    	if name == 'Quantum Book IV' then return true end
    
    	return false
    end
    
    local function displayBookGroupName(name)
    	name = name or ''
    	name = name:gsub('^Quantum%s+', '')
    	return name
    end
    
    local function renderOrdinaryIndexGroup(group, startNumber)
    	local out = {}
    	local n = startNumber or 1
    
    	table.insert(out, '<div style="break-inside:avoid; margin-bottom:0.8em;">')
    	table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(group.name) .. '</div>')
    
    	if #group.links > 0 then
    		table.insert(out, '<div style="margin-top:0; margin-left:1.2em;">')
    
    		for _, link in ipairs(group.links) do
    			local anchor = makeAnchorId(link.label)
    
    			if link.target:match('^Book:') then
    				table.insert(out, '<div>' .. n .. '. [[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</div>')
    				n = n + 1
    			else
    				table.insert(out, '<div>' .. n .. '. [[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</div>')
    				n = n + 1
    			end
    		end
    
    		table.insert(out, '</div>')
    	end
    
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n'), n
    end
    
    local function renderBookRow(bookGroups)
    	local out = {}
    
    	table.insert(out, '<div class="noexcerpt" style="margin:1em 0 0.8em 0;">')
    	table.insert(out, '<div style="display:flex; gap:2em; align-items:flex-start;">')
    
    	for _, group in ipairs(bookGroups) do
    		table.insert(out, '<div style="flex:1; min-width:0;">')
    		table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(displayBookGroupName(group.name)) .. '</div>')
    
    		if #group.links > 0 then
    			table.insert(out, '<ul style="margin-top:0;">')
    
    			for _, link in ipairs(group.links) do
    				if link.target:match('^Book:') then
    					table.insert(out, '<li>[[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</li>')
    				else
    					local anchor = makeAnchorId(link.label)
    					table.insert(out, '<li>[[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</li>')
    				end
    			end
    
    			table.insert(out, '</ul>')
    		end
    
    		table.insert(out, '</div>')
    	end
    
    	table.insert(out, '</div>')
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n')
    end
    
    local function renderIndex(groups)
    	if #groups == 0 then
    		return ''
    	end
    
    	local normalGroups = {}
    	local bookGroups = {}
    
    	for _, group in ipairs(groups) do
    		if isBookGroup(group) then
    			table.insert(bookGroups, group)
    		else
    			table.insert(normalGroups, group)
    		end
    	end
    
    	local out = {}
    
    	table.insert(out, '= Index =')
    
    	table.insert(out, '<div class="noexcerpt" style="column-count:2; column-gap:2em;">')
    
    	local indexNumber = 1
    
    	for _, group in ipairs(normalGroups) do
    		local rendered
    		rendered, indexNumber = renderOrdinaryIndexGroup(group, indexNumber)
    		table.insert(out, rendered)
    	end
    
    	table.insert(out, '</div>')
    
    	if #bookGroups > 0 then
    		table.insert(out, renderBookRow(bookGroups))
    	end
    
    	return table.concat(out, '\n')
    end
    
    -- ============================================================
    -- Parse == Full contents ==
    -- ============================================================
    
    local function parseFullContents(content)
    	local full = content:match('==%s*Full contents%s*==%s*(.*)') or ''
    	local sections = {}
    	local current = nil
    
    	for line in full:gmatch('[^\r\n]+') do
    		local heading = line:match('^===%s*(.-)%s*===%s*$')
    
    		if heading then
    			current = {
    				name = trim(heading),
    				lines = {}
    			}
    			table.insert(sections, current)
    		elseif current then
    			table.insert(current.lines, line)
    		end
    	end
    
    	return sections
    end
    
    local function forceOrderedList(body, startNumber)
    	body = body or ''
    	startNumber = startNumber or 1
    
    	-- Extra safety in case includeonly tags survive from copied content.
    	body = body:gsub('<%s*/?%s*includeonly%s*>', '')
    
    	-- If the data page already contains an ordered list, preserve it.
    	if body:match('<%s*ol[%s>]') then
    		return body, startNumber
    	end
    
    	local out = {}
    	local inList = false
    	local n = startNumber
    
    	for line in body:gmatch('([^\r\n]*)\r?\n?') do
    		if line ~= '' then
    			local liContent = line:match('^%s*<%s*li%s*>(.-)<%s*/%s*li%s*>%s*$')
    
    			if liContent then
    				if not inList then
    					table.insert(out, '<div style="margin-top:0; margin-left:1.6em;">')
    					inList = true
    				end
    
    				table.insert(out, '<div>' .. n .. '. ' .. liContent .. '</div>')
    				n = n + 1
    			else
    				if inList then
    					table.insert(out, '</div>')
    					inList = false
    				end
    
    				table.insert(out, line)
    			end
    		end
    	end
    
    	if inList then
    		table.insert(out, '</div>')
    	end
    
    	return table.concat(out, '\n'), n
    end
    
    local function renderSectionList(section, collapse, sectionNumber, articleStartNumber)
    	local body, nextArticleNumber = forceOrderedList(table.concat(section.lines, '\n'), articleStartNumber)
    	local count = countArticleLinks(body)
    	local anchor = makeAnchorId(section.name)
    	local collapsedClass = ''
    
    	if collapse then
    		collapsedClass = ' mw-collapsed'
    	end
    
    	local sectionLabel = section.name
    	if sectionNumber then
    		sectionLabel = tostring(sectionNumber) .. '. ' .. sectionLabel
    	end
    
    	local out = {}
    
    	table.insert(out, '<div id="' .. anchor .. '" class="mw-collapsible' .. collapsedClass .. '" style="border:1px solid #e0d890; background:#fffdf0; margin:0.8em 0; padding:0.6em;">')
    	table.insert(out, '<div style="font-weight:bold; margin-bottom:0.4em;">' .. escapeHtml(sectionLabel) .. ' (' .. count .. ') <span style="font-size:85%; font-weight:normal;">[[#Index|Back to index]]</span></div>')
    
    	-- IMPORTANT:
    	-- Full contents is intentionally ONE column and raw.
    	-- This preserves original list formatting and any images.
    	table.insert(out, '<div>')
    	table.insert(out, body)
    	table.insert(out, '</div>')
    
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n'), nextArticleNumber
    end
    
    local function renderFullContents(sections)
    	if #sections == 0 then
    		return ''
    	end
    
    	local out = {}
    	local collapse = false
    
    	table.insert(out, '= Full contents =')
    
    	local articleNumber = 1
    
    	for i, section in ipairs(sections) do
    		local rendered
    		rendered, articleNumber = renderSectionList(section, collapse, i, articleNumber)
    		table.insert(out, rendered)
    	end
    
    	return table.concat(out, '\n')
    end
    
    local function totalArticleCount(sections)
    	local n = 0
    
    	for _, section in ipairs(sections) do
    		n = n + countArticleLinks(table.concat(section.lines, '\n'))
    	end
    
    	return n
    end
    
    -- ============================================================
    -- Public TOC functions
    -- ============================================================
    
    function p.tocHeading(frame)
    	local args = frame.args or {}
    	local dataPage = args[1] or 'Physics:Quantum basics/See also'
    	local content = getPageContent(dataPage)
    	local sections = parseFullContents(content)
    	local n = totalArticleCount(sections)
    
    	return '= Table of contents (' .. n .. ' articles) ='
    end
    
    function p.tocHeadingAndList(frame)
    	local args = frame.args or {}
    	local dataPage = args[1] or 'Physics:Quantum basics/See also'
    	local content = getPageContent(dataPage)
    
    	local groups = parseIndex(content)
    	local sections = parseFullContents(content)
    	local n = totalArticleCount(sections)
    
    	local out = {}
    
    	table.insert(out, '= Table of contents (' .. n .. ' articles) =')
    	table.insert(out, renderIndex(groups))
    	table.insert(out, renderFullContents(sections))
    
    	return table.concat(out, '\n\n')
    end
    
    -- ============================================================
    -- Child-book wrappers
    -- ============================================================
    
    function p.qm(frame)
    	frame = frame or {}
    	frame.args = frame.args or {}
    	frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Matter'
    	return p.tocHeadingAndList(frame)
    end
    
    function p.qt(frame)
    	frame = frame or {}
    	frame.args = frame.args or {}
    	frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Methods'
    	return p.tocHeadingAndList(frame)
    end
    
    function p.qd(frame)
    	frame = frame or {}
    	frame.args = frame.args or {}
    	frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Data Analysis'
    	return p.tocHeadingAndList(frame)
    end
    
    -- ============================================================
    -- Gallery helpers
    -- ============================================================
    
    local function extractPagesForGallery(content)
    	local pages = {}
    	local seen = {}
    
    	local full = content:match('==%s*Full contents%s*==%s*(.*)') or content
    
    	for raw in full:gmatch('%[%[([^%]]+)%]%]') do
    		local target = raw:match('^([^|#]+)') or raw
    		target = trim(target)
    
    		if not isSkippedLink(target) and not target:match('^Book:') and not seen[target] then
    			seen[target] = true
    			table.insert(pages, target)
    		end
    	end
    
    	return pages
    end
    
    local function firstFileInArticle(page)
    	local text = getPageContent(page)
    
    	if text == '' then
    		return nil
    	end
    
    	local file = text:match('%[%[%s*[Ff]ile%s*:%s*([^%]|%]]+)')
    
    	if not file then
    		file = text:match('%[%[%s*[Ii]mage%s*:%s*([^%]|%]]+)')
    	end
    
    	if file then
    		file = trim(file)
    		file = file:gsub('|.*$', '')
    		file = trim(file)
    	end
    
    	return file
    end
    
    local function normalizeGalleryWidth(value)
    	local width = tonumber(value) or 150
    
    	if width < 40 then
    		width = 40
    	elseif width > 400 then
    		width = 400
    	end
    
    	return math.floor(width)
    end
    
    local function renderGalleryCard(page, file, width)
    	local label = stripNamespaceForLabel(page)
    	local out = {}
    
    	table.insert(out, '<div style="display:inline-block; vertical-align:top; width:180px; margin:6px; padding:5px; border:1px solid #e0d890; background:#fff8cc; text-align:center;">')
    	if file then
    		table.insert(out, '[[File:' .. file .. '|' .. width .. 'px]]')
    	else
    		table.insert(out, '<div style="height:120px; display:flex; align-items:center; justify-content:center; border:1px dashed #c0a850; background:#fffdf0; color:#900; font-size:90%;">No image found</div>')
    	end
    
    	table.insert(out, '<div style="font-size:90%; line-height:1.25; margin-top:4px;">[[' .. page .. '|' .. escapeHtml(label) .. ']]</div>')
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n')
    end
    
    -- ============================================================
    -- Public gallery function
    -- ============================================================
    
    function p.gallery(frame)
    	local args = frame.args or {}
    	local dataPage = args[1] or 'Physics:Quantum basics/See also'
    	local width = normalizeGalleryWidth(args.width or args[2])
    	local content = getPageContent(dataPage)
    
    	local pages = extractPagesForGallery(content)
    
    	local out = {}
    	local imageCount = 0
    	local missingCount = 0
    	local pageCount = #pages
    
    	table.insert(out, '<div class="noexcerpt" style="max-width:100%; clear:both;">')
    
    	for _, page in ipairs(pages) do
    		local file = firstFileInArticle(page)
    
    		if file then
    			imageCount = imageCount + 1
    		else
    			missingCount = missingCount + 1
    		end
    
    		table.insert(out, renderGalleryCard(page, file, width))
    	end
    
    	table.insert(out, '</div>')
    
    	local heading = '== Gallery (' .. imageCount .. ' images, ' .. missingCount .. ' missing, ' .. pageCount .. ' pages) =='
    
    	return heading .. '\n' .. table.concat(out, '\n')
    end
    
    return p