Module:PhysicsQC

From HandWiki Stage
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 (185 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 = {}
    
    -- ============================================================
    -- 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 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