Fix breadcrumb type link: use raw HTML <a> tag via mw.html.create() instead of wikitext external link — eliminates nofollow and external link icon (via update-page on MediaWiki MCP Server)
Fix badge: replace broken mw.html.create('a') with plain wikilinks (MediaWiki sanitizer strips raw a tags) (via update-page on MediaWiki MCP Server)
Line 298: Line 298:
)
)


-- Type badge (breadcrumb) — raw <a> tag avoids nofollow/external decoration
-- Type badge (breadcrumb) — plain wikilinks avoid nofollow and external-link icon
-- To enable pre-filtered type links later, add wiki domain to $wgNoFollowDomainExceptions
local badgeWikitext = '[[CRT Search|CRT]]'
local badgeWikitext = '[[CRT Search|CRT]]'
if crt_type then
if crt_type then
local typeUrl = mw.title.new('CRT Search'):fullUrl({crt_type = crt_type})
badgeWikitext = badgeWikitext .. ' › [[CRT Search|' .. crt_type .. ']]'
local typeLink = tostring(mw.html.create('a'):attr('href', typeUrl):wikitext(crt_type))
badgeWikitext = badgeWikitext .. ' ' .. typeLink
end
end
addFullRow(container, badgeWikitext, '', false, 'infobox-type-badge')
addFullRow(container, badgeWikitext, '', false, 'infobox-type-badge')
Line 414: Line 413:
if chassis_family then
if chassis_family then
-- Link directly to the chassis family page (no namespace prefix)
local familyWikitext = string.format(
local familyWikitext = string.format(
'{{#formredlink: target=%s |form=Chassis family |existing page link text=%s }}',
'{{#formredlink: target=%s |form=Chassis family |existing page link text=%s }}',
Line 422: Line 420:
if chassis then
if chassis then
-- Both: show chassis with family in parentheses on next line
chassisDisplay = chassisDisplay .. '<br><small>Family: ' .. familyHtml .. '</small>'
chassisDisplay = chassisDisplay .. '<br><small>Family: ' .. familyHtml .. '</small>'
else
else
-- Only family: show family alone
chassisDisplay = familyHtml .. ' <small>(family)</small>'
chassisDisplay = familyHtml .. ' <small>(family)</small>'
end
end
Line 444: Line 440:
end
end


-- ECID row: build as a normal external link wikitext; preprocess not required, but fine.
-- ECID row
local ecidWikitext = string.format('[{{fullurl:%s|curid=%d}} EC%d]', pageName, pageId, pageId)
local ecidWikitext = string.format('[{{fullurl:%s|curid=%d}} EC%d]', pageName, pageId, pageId)
addRow(container, 'ECID', frame:preprocess(ecidWikitext), true)
addRow(container, 'ECID', frame:preprocess(ecidWikitext), true)


-- Tube section: show if we have any tube specs
-- Tube section
if mask_type or pitch or phosphor_type or gun_type or deflection_angle or neck_diameter or visible_w or visible_h or visible_diag then
if mask_type or pitch or phosphor_type or gun_type or deflection_angle or neck_diameter or visible_w or visible_h or visible_diag then
addSectionRow(container, 'Tube')
addSectionRow(container, 'Tube')
if mask_type then addRow(container, 'Mask', mask_type, false) end
if mask_type then
if pitch then addRow(container, 'Pitch', pitch .. ' mm', false) end
addRow(container, 'Mask', mask_type, false)
if phosphor_type then addRow(container, 'Phosphors', phosphor_type, false) end
end
if gun_type then addRow(container, 'Gun type', gun_type, false) end
if deflection_angle then addRow(container, 'Deflection', deflection_angle .. '°', false) end
if pitch then
if neck_diameter then addRow(container, 'Neck', neck_diameter .. ' mm', false) end
addRow(container, 'Pitch', pitch .. ' mm', false)
end
if phosphor_type then
addRow(container, 'Phosphors', phosphor_type, false)
end
if gun_type then
addRow(container, 'Gun type', gun_type, false)
end
if deflection_angle then
addRow(container, 'Deflection', deflection_angle .. '°', false)
end
if neck_diameter then
addRow(container, 'Neck', neck_diameter .. ' mm', false)
end
-- Visible picture size
if visible_w and visible_h then
if visible_w and visible_h then
addRow(container, 'Visible area', visible_w .. ' × ' .. visible_h .. ' mm', false)
addRow(container, 'Visible area', visible_w .. ' × ' .. visible_h .. ' mm', false)
Line 482: Line 458:
addRow(container, 'Visible area', visible_diag .. '″ (diagonal)', false)
addRow(container, 'Visible area', visible_diag .. '″ (diagonal)', false)
end
end
-- Show diagonal even if we have W×H, as supplementary info
if visible_diag and visible_w and visible_h then
if visible_diag and visible_w and visible_h then
addRow(container, 'Visible diag.', visible_diag .. '″', false)
addRow(container, 'Visible diag.', visible_diag .. '″', false)
Line 489: Line 463:
end
end


-- Dimensions section: show if we have height/width/depth OR weight
-- Dimensions section
if height or width or depth or weight then
if height or width or depth or weight then
addSectionRow(container, 'Dimensions')
addSectionRow(container, 'Dimensions')
if height or width or depth then
if height or width or depth then
local h = height or '–'
local h = height or '–'
Line 499: Line 472:
addRow(container, 'H × W × D', string.format('%s × %s × %s', h, w, d), false)
addRow(container, 'H × W × D', string.format('%s × %s × %s', h, w, d), false)
end
end
-- Weight row: uses #show, so preprocess it
if weight then
if weight then
local weightWikitext = '{{#show: ' .. fullPageName .. ' |?Weight # lb }} / {{#show: ' .. fullPageName .. ' |?Weight # kg }}'
local weightWikitext = '{{#show: ' .. fullPageName .. ' |?Weight # lb }} / {{#show: ' .. fullPageName .. ' |?Weight # kg }}'
Line 507: Line 478:
end
end


-- Speakers section: show if we have any speaker specs
-- Speakers section
if speaker_type or speaker_watts or subwoofer then
if speaker_type or speaker_watts or subwoofer then
addSectionRow(container, 'Speakers')
addSectionRow(container, 'Speakers')
if speaker_type then addRow(container, 'Type', speaker_type, false) end
if speaker_type then
if speaker_watts then addRow(container, 'Power', speaker_watts, false) end
addRow(container, 'Type', speaker_type, false)
if subwoofer then addRow(container, 'Subwoofer', subwoofer, false) end
end
if speaker_watts then
addRow(container, 'Power', speaker_watts, false)
end
if subwoofer then
addRow(container, 'Subwoofer', subwoofer, false)
end
end
end


-- Power section: query voltage ranges from subobjects
-- Power section
-- First, query for voltage range subobjects
local voltageQueryWikitext =
local voltageQueryWikitext =
'{{#ask:\n' ..
'{{#ask:\n' ..
Line 539: Line 500:
'}}'
'}}'
local voltageResult = frame:preprocess(voltageQueryWikitext)
local voltageResult = frame:preprocess(voltageQueryWikitext)
-- Parse voltage ranges from broadtable result
local voltageRanges = {}
local voltageRanges = {}
if voltageResult then
if voltageResult then
Line 549: Line 508:
table.insert(cells, trim(cleanContent))
table.insert(cells, trim(cleanContent))
end
end
-- cells[1] = subobject, cells[2] = voltage_min, cells[3] = voltage_max
if #cells >= 3 then
if #cells >= 3 then
local vMin = tonumber(cells[2])
local vMin = tonumber(cells[2])
Line 560: Line 517:
end
end
end
end
local hasVoltageRanges = #voltageRanges > 0
local hasVoltageRanges = #voltageRanges > 0


-- Show Power section if we have voltage ranges, frequency, or power consumption
if hasVoltageRanges or frequency or power_avg or power_max then
if hasVoltageRanges or frequency or power_avg or power_max then
addSectionRow(container, 'Power')
addSectionRow(container, 'Power')
-- Voltage row: display ranges from subobjects
if hasVoltageRanges then
if hasVoltageRanges then
local rangeStrings = {}
local rangeStrings = {}
Line 583: Line 536:
rangeStr = range.max .. 'V'
rangeStr = range.max .. 'V'
end
end
if rangeStr then
if rangeStr then table.insert(rangeStrings, rangeStr) end
table.insert(rangeStrings, rangeStr)
end
end
end
if #rangeStrings > 0 then
if #rangeStrings > 0 then
addRow(container, 'Voltage', table.concat(rangeStrings, ' / '), false)
addRow(container, 'Voltage', table.concat(rangeStrings, ' / '), false)
end
end
end
end
if frequency then addRow(container, 'Frequency', frequency, false) end
-- Frequency row
if frequency then
addRow(container, 'Frequency', frequency, false)
end
-- Power consumption row
if power_avg or power_max then
if power_avg or power_max then
local powerDisplay
local powerDisplay
Line 612: Line 556:
end
end


-- TV Systems section: show if we have AV system or tuner system
-- TV Systems section
if av_system or tuner_system then
if av_system or tuner_system then
addSectionRow(container, 'TV Systems')
addSectionRow(container, 'TV Systems')
if av_system then
if av_system then
local systems = {}
local systems = {}
for system in av_system:gmatch('[^,]+') do
for system in av_system:gmatch('[^,]+') do
local s = trim(system)
local s = trim(system)
if s then
if s then table.insert(systems, '[[' .. s .. '|' .. s .. ']]') end
table.insert(systems, '[[' .. s .. '|' .. s .. ']]')
end
end
if #systems > 0 then
addRow(container, 'AV system', table.concat(systems, ', '), true)
end
end
if #systems > 0 then addRow(container, 'AV system', table.concat(systems, ', '), true) end
end
end
if tuner_system then
if tuner_system then
local systems = {}
local systems = {}
for system in tuner_system:gmatch('[^,]+') do
for system in tuner_system:gmatch('[^,]+') do
local s = trim(system)
local s = trim(system)
if s then
if s then table.insert(systems, '[[' .. s .. '|' .. s .. ']]') end
table.insert(systems, '[[' .. s .. '|' .. s .. ']]')
end
end
if #systems > 0 then
addRow(container, 'Tuner', table.concat(systems, ', '), true)
end
end
if #systems > 0 then addRow(container, 'Tuner', table.concat(systems, ', '), true) end
end
end
end
end
Line 646: Line 580:
if h_scan_min or h_scan_max or v_scan_min or v_scan_max then
if h_scan_min or h_scan_max or v_scan_min or v_scan_max then
addSectionRow(container, 'Scan Range')
addSectionRow(container, 'Scan Range')
if h_scan_min or h_scan_max then
if h_scan_min or h_scan_max then
local hDisplay
local hDisplay
if h_scan_min and h_scan_max then
if h_scan_min and h_scan_max then
if h_scan_min == h_scan_max then
hDisplay = (h_scan_min == h_scan_max) and (h_scan_min .. ' kHz') or (h_scan_min .. '–' .. h_scan_max .. ' kHz')
hDisplay = h_scan_min .. ' kHz'
else
hDisplay = h_scan_min .. '–' .. h_scan_max .. ' kHz'
end
elseif h_scan_min then
hDisplay = h_scan_min .. ' kHz'
else
else
hDisplay = h_scan_max .. ' kHz'
hDisplay = (h_scan_min or h_scan_max) .. ' kHz'
end
end
addRow(container, 'Horizontal', hDisplay, false)
addRow(container, 'Horizontal', hDisplay, false)
end
end
if v_scan_min or v_scan_max then
if v_scan_min or v_scan_max then
local vDisplay
local vDisplay
if v_scan_min and v_scan_max then
if v_scan_min and v_scan_max then
if v_scan_min == v_scan_max then
vDisplay = (v_scan_min == v_scan_max) and (v_scan_min .. ' Hz') or (v_scan_min .. '–' .. v_scan_max .. ' Hz')
vDisplay = v_scan_min .. ' Hz'
else
vDisplay = v_scan_min .. '–' .. v_scan_max .. ' Hz'
end
elseif v_scan_min then
vDisplay = v_scan_min .. ' Hz'
else
else
vDisplay = v_scan_max .. ' Hz'
vDisplay = (v_scan_min or v_scan_max) .. ' Hz'
end
end
addRow(container, 'Vertical', vDisplay, false)
addRow(container, 'Vertical', vDisplay, false)
Line 684: Line 604:
local locations = discoverLocations(frame, fullPageName, ioType)
local locations = discoverLocations(frame, fullPageName, ioType)
local locationData = {}
local locationData = {}
for _, loc in ipairs(locations) do
for _, loc in ipairs(locations) do
local content = queryIOByLocation(frame, fullPageName, ioType, loc)
local content = queryIOByLocation(frame, fullPageName, ioType, loc)
Line 691: Line 610:
end
end
end
end
if #locationData > 0 then
if #locationData > 0 then
addSectionRow(container, sectionTitle)
addSectionRow(container, sectionTitle)
Line 699: Line 617:
css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
labelDiv:wikitext(locationLabel(data.location))
labelDiv:wikitext(locationLabel(data.location))
local valueDiv = container:tag('div')
local valueDiv = container:tag('div')
valueDiv:addClass('infobox-value')
valueDiv:addClass('infobox-value')
Line 714: Line 631:
-- Accessories section
-- Accessories section
addSectionRow(container, 'Accessories')
addSectionRow(container, 'Accessories')
 
local addAccessoryUrl = mw.uri.fullUrl("Special:FormEdit", {form = "Accessory", ["Accessory[crt_models]"] = pageName})
local addAccessoryUrl = mw.uri.fullUrl(
"Special:FormEdit",
{
form = "Accessory",
["Accessory[crt_models]"] = pageName
}
)
 
local accessoriesCountWikitext =
local accessoriesCountWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[Category:Accessories]]\n [[Has related CRT model::' .. fullPageName .. ']]\n [[Has accessory type::!Remote control]]\n |format=count\n}}'
' [[Category:Accessories]]\n' ..
' [[Has related CRT model::' .. fullPageName .. ']]\n' ..
' [[Has accessory type::!Remote control]]\n' ..
' |format=count\n' ..
'}}'
local accessoryCount = frame:preprocess(accessoriesCountWikitext)
local accessoryCount = frame:preprocess(accessoriesCountWikitext)
local hasAccessories = accessoryCount and tonumber(trim(accessoryCount)) and tonumber(trim(accessoryCount)) > 0
local hasAccessories = accessoryCount and tonumber(trim(accessoryCount)) and tonumber(trim(accessoryCount)) > 0
Line 738: Line 642:
if hasAccessories then
if hasAccessories then
local accessoriesQueryWikitext =
local accessoriesQueryWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[Category:Accessories]]\n [[Has related CRT model::' .. fullPageName .. ']]\n [[Has accessory type::!Remote control]]\n |?Has accessory type=type\n |?Has model code=model\n |format=broadtable\n |headers=hide\n |link=none\n}}'
' [[Category:Accessories]]\n' ..
' [[Has related CRT model::' .. fullPageName .. ']]\n' ..
' [[Has accessory type::!Remote control]]\n' ..
' |?Has accessory type=type\n' ..
' |?Has model code=model\n' ..
' |format=broadtable\n' ..
' |headers=hide\n' ..
' |link=none\n' ..
'}}'
local accessoriesResult = frame:preprocess(accessoriesQueryWikitext)
local accessoriesResult = frame:preprocess(accessoriesQueryWikitext)
local accessoriesByType = {}
local accessoriesByType = {}
local typeOrder = {}
local typeOrder = {}
if accessoriesResult then
if accessoriesResult then
for rowContent in accessoriesResult:gmatch('<tr[^>]*>(.-)</tr>') do
for rowContent in accessoriesResult:gmatch('<tr[^>]*>(.-)</tr>') do
local cells = {}
local cells = {}
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
local cleanContent = cellContent:gsub('<[^>]+>', '')
table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
table.insert(cells, trim(cleanContent))
end
end
if #cells >= 2 then
if #cells >= 2 then
local accPageName = cells[1]
local accPageName = cells[1]
local accType = cells[2] or 'Accessory'
local accType = cells[2] or 'Accessory'
if accPageName then
if accPageName then
if not accessoriesByType[accType] then
if not accessoriesByType[accType] then
Line 770: Line 660:
table.insert(typeOrder, accType)
table.insert(typeOrder, accType)
end
end
 
table.insert(accessoriesByType[accType], {page = accPageName})
table.insert(accessoriesByType[accType], {
page = accPageName
})
end
end
end
end
end
end
end
end
local accGrid = accDiv:tag('div')
local accGrid = accDiv:tag('div')
css(accGrid, 'display:grid; grid-template-columns:auto 1fr; gap:3px;')
css(accGrid, 'display:grid; grid-template-columns:auto 1fr; gap:3px;')
for _, accType in ipairs(typeOrder) do
for _, accType in ipairs(typeOrder) do
local accs = accessoriesByType[accType]
local accs = accessoriesByType[accType]
if accs and #accs > 0 then
if accs and #accs > 0 then
local accLinks = {}
local accLinks = {}
for _, acc in ipairs(accs) do
for _, acc in ipairs(accs) do table.insert(accLinks, '[[' .. acc.page .. ']]') end
table.insert(accLinks, '[[' .. acc.page .. ']]')
end
 
local labelDiv = accGrid:tag('div')
local labelDiv = accGrid:tag('div')
labelDiv:addClass('infobox-label')
labelDiv:addClass('infobox-label')
css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
labelDiv:wikitext(accType)
labelDiv:wikitext(accType)
local valueDiv = accGrid:tag('div')
local valueDiv = accGrid:tag('div')
valueDiv:addClass('infobox-value')
valueDiv:addClass('infobox-value')
Line 801: Line 682:
end
end
end
end
local addAccDiv = mw.html.create('div')
local addAccDiv = mw.html.create('div')
addAccDiv:css('margin-top', '0.5em'):css('text-align', 'center')
addAccDiv:css('margin-top', '0.5em'):css('text-align', 'center')
Line 815: Line 695:
-- Parts section
-- Parts section
addSectionRow(container, 'Parts')
addSectionRow(container, 'Parts')
local addPartUrl = mw.uri.fullUrl("Special:FormEdit", {form = "Part", ["Part[crt_models]"] = pageName})
local addPartUrl = mw.uri.fullUrl(
"Special:FormEdit",
{
form = "Part",
["Part[crt_models]"] = pageName
}
)
local partsCountWikitext =
local partsCountWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[Category:Parts]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |format=count\n}}'
' [[Category:Parts]]\n' ..
' [[Has related CRT model::' .. fullPageName .. ']]\n' ..
' |format=count\n' ..
'}}'
local partsCount = frame:preprocess(partsCountWikitext)
local partsCount = frame:preprocess(partsCountWikitext)
local hasParts = partsCount and tonumber(trim(partsCount)) and tonumber(trim(partsCount)) > 0
local hasParts = partsCount and tonumber(trim(partsCount)) and tonumber(trim(partsCount)) > 0
local partsDiv = container:tag('div')
local partsDiv = container:tag('div')
css(partsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')
css(partsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')
 
if hasParts then
if hasParts then
local partsQueryWikitext =
local partsQueryWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[Category:Parts]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |?Has part type=type\n |?Has part subtype=subtype\n |?Has model code=model\n |format=broadtable\n |headers=hide\n |link=none\n}}'
' [[Category:Parts]]\n' ..
' [[Has related CRT model::' .. fullPageName .. ']]\n' ..
' |?Has part type=type\n' ..
' |?Has part subtype=subtype\n' ..
' |?Has model code=model\n' ..
' |format=broadtable\n' ..
' |headers=hide\n' ..
' |link=none\n' ..
'}}'
local partsResult = frame:preprocess(partsQueryWikitext)
local partsResult = frame:preprocess(partsQueryWikitext)
local partsByType = {}
local partsByType = {}
local partTypeOrder = {}
local partTypeOrder = {}
if partsResult then
if partsResult then
for rowContent in partsResult:gmatch('<tr[^>]*>(.-)</tr>') do
for rowContent in partsResult:gmatch('<tr[^>]*>(.-)</tr>') do
local cells = {}
local cells = {}
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
local cleanContent = cellContent:gsub('<[^>]+>', '')
table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
table.insert(cells, trim(cleanContent))
end
end
if #cells >= 2 then
if #cells >= 2 then
local partPageName = cells[1]
local partPageName = cells[1]
local partType = cells[2] or 'Part'
local partType = cells[2] or 'Part'
local partSubtype = cells[3]
local partSubtype = cells[3]
local groupKey = partType
local groupKey = partType
if partSubtype and partSubtype ~= '' then
if partSubtype and partSubtype ~= '' then groupKey = partSubtype .. ' ' .. partType end
groupKey = partSubtype .. ' ' .. partType
end
if partPageName then
if partPageName then
if not partsByType[groupKey] then
if not partsByType[groupKey] then
Line 876: Line 726:
table.insert(partTypeOrder, groupKey)
table.insert(partTypeOrder, groupKey)
end
end
table.insert(partsByType[groupKey], {page = partPageName})
table.insert(partsByType[groupKey], {
page = partPageName
})
end
end
end
end
end
end
end
end
local partsGrid = partsDiv:tag('div')
local partsGrid = partsDiv:tag('div')
css(partsGrid, 'display:grid; grid-template-columns:auto 1fr; gap:3px;')
css(partsGrid, 'display:grid; grid-template-columns:auto 1fr; gap:3px;')
for _, partType in ipairs(partTypeOrder) do
for _, partType in ipairs(partTypeOrder) do
local parts = partsByType[partType]
local parts = partsByType[partType]
if parts and #parts > 0 then
if parts and #parts > 0 then
local partLinks = {}
local partLinks = {}
for _, part in ipairs(parts) do
for _, part in ipairs(parts) do table.insert(partLinks, '[[' .. part.page .. ']]') end
table.insert(partLinks, '[[' .. part.page .. ']]')
end
local labelDiv = partsGrid:tag('div')
local labelDiv = partsGrid:tag('div')
labelDiv:addClass('infobox-label')
labelDiv:addClass('infobox-label')
css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
labelDiv:wikitext(partType)
labelDiv:wikitext(partType)
local valueDiv = partsGrid:tag('div')
local valueDiv = partsGrid:tag('div')
valueDiv:addClass('infobox-value')
valueDiv:addClass('infobox-value')
Line 907: Line 748:
end
end
end
end
local addPartDiv = mw.html.create('div')
local addPartDiv = mw.html.create('div')
addPartDiv:css('margin-top', '0.5em'):css('text-align', 'center')
addPartDiv:css('margin-top', '0.5em'):css('text-align', 'center')
Line 921: Line 761:
-- Mods section
-- Mods section
local modsQueryWikitext =
local modsQueryWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[Has CRT model::' .. fullPageName .. ']]\n [[Has mod URL::+]]\n |?Has mod type=type\n |?Has mod description=description\n |?Has mod feasible=feasible\n |?Has mod URL=url\n |format=broadtable\n |headers=hide\n |link=none\n}}'
' [[Has CRT model::' .. fullPageName .. ']]\n' ..
' [[Has mod URL::+]]\n' ..
' |?Has mod type=type\n' ..
' |?Has mod description=description\n' ..
' |?Has mod feasible=feasible\n' ..
' |?Has mod URL=url\n' ..
' |format=broadtable\n' ..
' |headers=hide\n' ..
' |link=none\n' ..
'}}'
local modsResult = frame:preprocess(modsQueryWikitext)
local modsResult = frame:preprocess(modsQueryWikitext)
local mods = {}
local mods = {}
if modsResult then
if modsResult then
for rowContent in modsResult:gmatch('<tr[^>]*>(.-)</tr>') do
for rowContent in modsResult:gmatch('<tr[^>]*>(.-)</tr>') do
local cells = {}
local cells = {}
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
local cleanContent = cellContent:gsub('<[^>]+>', '')
table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
table.insert(cells, trim(cleanContent))
end
end
if #cells >= 3 then
if #cells >= 3 then
local modData = {
local modData = {type = nil, description = nil, feasible = nil, url = nil}
type = nil,
description = nil,
feasible = nil,
url = nil
}
for i = 2, #cells do
for i = 2, #cells do
local cell = cells[i]
local cell = cells[i]
if cell then
if cell then
if cell:match('^https?://') then
if cell:match('^https?://') then modData.url = cell
modData.url = cell
elseif cell == 'Yes' or cell == 'No' then modData.feasible = cell
elseif cell == 'Yes' or cell == 'No' then
elseif cell == 'RGB' or cell == 'S-video' or cell == 'Other' then modData.type = cell
modData.feasible = cell
else modData.description = cell end
elseif cell == 'RGB' or cell == 'S-video' or cell == 'Other' then
modData.type = cell
else
modData.description = cell
end
end
end
end
end
if modData.url and modData.type then table.insert(mods, modData) end
if modData.url and modData.type then
table.insert(mods, modData)
end
end
end
end
end
end
end
if #mods > 0 then
if #mods > 0 then
addSectionRow(container, 'Mods')
addSectionRow(container, 'Mods')
local modsDiv = container:tag('div')
local modsDiv = container:tag('div')
css(modsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')
css(modsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')
local ul = modsDiv:tag('ul')
local ul = modsDiv:tag('ul')
ul:css('margin', '0'):css('padding-left', '1.5em')
ul:css('margin', '0'):css('padding-left', '1.5em')
for _, mod in ipairs(mods) do
for _, mod in ipairs(mods) do
local li = ul:tag('li')
local li = ul:tag('li')
local displayText = mod.description or mod.type
local displayText = mod.description or mod.type
local isFeasible = mod.feasible ~= 'No'
local isFeasible = mod.feasible ~= 'No'
if isFeasible then
if isFeasible then
li:wikitext('[' .. mod.url .. ' ' .. displayText .. ']')
li:wikitext('[' .. mod.url .. ' ' .. displayText .. ']')
Line 1,004: Line 810:
-- Documents section
-- Documents section
addSectionRow(container, 'Documents')
addSectionRow(container, 'Documents')
local timestamp = os.time()
local timestamp = os.time()
math.randomseed(timestamp + pageId)
math.randomseed(timestamp + pageId)
local random = math.random(1000, 9999)
local random = math.random(1000, 9999)
local target = "Document:" .. timestamp .. "-" .. random
local target = "Document:" .. timestamp .. "-" .. random
local uploadUrl = mw.uri.fullUrl(
local uploadUrl = mw.uri.fullUrl("Special:FormEdit", {form = "Document", target = target, ["Document[crt_models]"] = pageName})
"Special:FormEdit",
{
form = "Document",
target = target,
["Document[crt_models]"] = pageName
}
)
local documentsCountWikitext =
local documentsCountWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[Category:Documents]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |format=count\n}}'
' [[Category:Documents]]\n' ..
' [[Has related CRT model::' .. fullPageName .. ']]\n' ..
' |format=count\n' ..
'}}'
local documentCount = frame:preprocess(documentsCountWikitext)
local documentCount = frame:preprocess(documentsCountWikitext)
local hasDocuments = documentCount and tonumber(trim(documentCount)) and tonumber(trim(documentCount)) > 0
local hasDocuments = documentCount and tonumber(trim(documentCount)) and tonumber(trim(documentCount)) > 0
local docsDiv = container:tag('div')
local docsDiv = container:tag('div')
css(docsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')
css(docsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')
 
if hasDocuments then
if hasDocuments then
local docsQueryWikitext =
local docsQueryWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[Category:Documents]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |?Has document type\n |?Has document URL\n |?Has document file\n |?Has document title\n |format=broadtable\n |headers=hide\n |link=none\n}}'
' [[Category:Documents]]\n' ..
' [[Has related CRT model::' .. fullPageName .. ']]\n' ..
' |?Has document type\n' ..
' |?Has document URL\n' ..
' |?Has document file\n' ..
' |?Has document title\n' ..
' |format=broadtable\n' ..
' |headers=hide\n' ..
' |link=none\n' ..
'}}'
local docsResult = frame:preprocess(docsQueryWikitext)
local docsResult = frame:preprocess(docsQueryWikitext)
local ul = mw.html.create('ul')
local ul = mw.html.create('ul')
ul:css('margin', '0'):css('padding-left', '1.5em')
ul:css('margin', '0'):css('padding-left', '1.5em')
if docsResult then
if docsResult then
for rowContent in docsResult:gmatch('<tr[^>]*>(.-)</tr>') do
for rowContent in docsResult:gmatch('<tr[^>]*>(.-)</tr>') do
local cells = {}
local cells = {}
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
local cleanContent = cellContent:gsub('<[^>]+>', '')
table.insert(cells, cellContent:gsub('<[^>]+>', ''))
table.insert(cells, cleanContent)
end
end
if #cells >= 2 then
if #cells >= 2 then
local docType = trim(cells[2])
local docType = trim(cells[2])
Line 1,061: Line 839:
local docFile = trim(cells[4])
local docFile = trim(cells[4])
local docTitle = trim(cells[5])
local docTitle = trim(cells[5])
local linkTarget = nil
if docUrl then
linkTarget = docUrl
elseif docFile then
local filename = docFile:match('File:(.+)') or docFile
linkTarget = tostring(mw.uri.fullUrl('File:' .. filename))
end
local docPageName = trim(cells[1])
local docPageName = trim(cells[1])
if docPageName then
if docPageName then
local displayText = 'Document'
local displayText = 'Document'
if docTitle then
if docTitle then displayText = docTitle
displayText = docTitle
elseif docType then displayText = docType end
elseif docType then
displayText = docType
end
local li = ul:tag('li')
local li = ul:tag('li')
li:wikitext('[[' .. docPageName .. '|' .. displayText .. ']]')
li:wikitext('[[' .. docPageName .. '|' .. displayText .. ']]')
Line 1,086: Line 850:
end
end
end
end
docsDiv:node(ul)
docsDiv:node(ul)
local uploadDiv = mw.html.create('div')
local uploadDiv = mw.html.create('div')
uploadDiv:css('margin-top', '0.5em'):css('text-align', 'center')
uploadDiv:css('margin-top', '0.5em'):css('text-align', 'center')
uploadDiv:wikitext('[' .. tostring(uploadUrl) .. ' <span class="infobox-button infobox-button-secondary">Add document</span>]')
uploadDiv:wikitext('[' .. tostring(uploadUrl) .. ' <span class="infobox-button infobox-button-secondary">Add document</span>]')
docsDiv:node(uploadDiv)
docsDiv:node(uploadDiv)
else
else
Line 1,098: Line 859:
uploadDiv:css('text-align', 'center')
uploadDiv:css('text-align', 'center')
uploadDiv:wikitext('[' .. tostring(uploadUrl) .. ' <span class="infobox-button infobox-button-secondary">Add document</span>]')
uploadDiv:wikitext('[' .. tostring(uploadUrl) .. ' <span class="infobox-button infobox-button-secondary">Add document</span>]')
docsDiv:node(uploadDiv)
docsDiv:node(uploadDiv)
end
end
Line 1,104: Line 864:
-- Links section
-- Links section
local linksQueryWikitext =
local linksQueryWikitext =
'{{#ask:\n' ..
'{{#ask:\n [[CRT model::' .. fullPageName .. ']]\n |?Model link type\n |?Model link source\n |?Model link URL\n |format=broadtable\n |headers=hide\n |link=none\n}}'
' [[CRT model::' .. fullPageName .. ']]\n' ..
' |?Model link type\n' ..
' |?Model link source\n' ..
' |?Model link URL\n' ..
' |format=broadtable\n' ..
' |headers=hide\n' ..
' |link=none\n' ..
'}}'
local linksResult = frame:preprocess(linksQueryWikitext)
local linksResult = frame:preprocess(linksQueryWikitext)
local linksByType = {Manufacturer = {}, Retailer = {}, Database = {}}
local linksByType = {
Manufacturer = {},
Retailer = {},
Database = {}
}
local hasLinks = false
local hasLinks = false
if linksResult then
if linksResult then
for rowContent in linksResult:gmatch('<tr[^>]*>(.-)</tr>') do
for rowContent in linksResult:gmatch('<tr[^>]*>(.-)</tr>') do
local cells = {}
local cells = {}
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
local cleanContent = cellContent:gsub('<[^>]+>', '')
table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
table.insert(cells, trim(cleanContent))
end
end
if #cells >= 4 then
if #cells >= 4 then
local linkType = cells[2]
local linkType = cells[2]
local linkSource = cells[3]
local linkSource = cells[3]
local linkUrl = cells[4]
local linkUrl = cells[4]
if linkType and linkSource and linkUrl and linkUrl ~= '' and linksByType[linkType] then
if linkType and linkSource and linkUrl and linkUrl ~= '' then
table.insert(linksByType[linkType], {source = linkSource, url = linkUrl})
if linksByType[linkType] then
hasLinks = true
table.insert(linksByType[linkType], {source = linkSource, url = linkUrl})
hasLinks = true
end
end
end
end
end
end
end
end
end
if hasLinks then
if hasLinks then
addSectionRow(container, 'Archival Links')
addSectionRow(container, 'Archival Links')
local linkTypeOrder = {'Database', 'Manufacturer', 'Retailer'}
local linkTypeOrder = {'Database', 'Manufacturer', 'Retailer'}
for _, linkType in ipairs(linkTypeOrder) do
for _, linkType in ipairs(linkTypeOrder) do
Line 1,162: Line 901:
-- Contribute section
-- Contribute section
addSectionRow(container, 'Contribute')
addSectionRow(container, 'Contribute')
local editUrl = mw.uri.fullUrl("Special:FormStart", {form = "CRT model", page_name = pageName})
local editUrl = mw.uri.fullUrl(
"Special:FormStart",
{
form = "CRT model",
page_name = pageName
}
)
local editDiv = container:tag('div')
local editDiv = container:tag('div')
css(editDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em; text-align:center;')
css(editDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em; text-align:center;')
editDiv:wikitext('[' .. tostring(editUrl) .. ' <span class="infobox-button infobox-button-primary">Edit this data</span>]')
editDiv:wikitext('[' .. tostring(editUrl) .. ' <span class="infobox-button infobox-button-primary">Edit this data</span>]')



Revision as of 04:32, 17 February 2026

Documentation for this module may be created at Module:CRTModel/doc

-- Module:CRTModel
-- Builds the CRT infobox as strict HTML to avoid whitespace -> <p><br></p>
-- Uses frame:preprocess() so parser functions like #ask and #formredlink evaluate.

local p = {}

local function trim(s)
	if s == nil then return nil end
	s = tostring(s)
	s = s:gsub('^%s+', ''):gsub('%s+$', '')
	if s == '' then return nil end
	return s
end

local function css(node, cssText)
	if cssText and cssText ~= '' then
		node:cssText(cssText)
	end
	return node
end

local function addSectionRow(container, title)
	local section = container:tag('div')
	section:addClass('infobox-section-header')
	css(section, 'grid-column: 1 / -1; text-align:center; padding:0.5em; font-weight:bold;')
	section:wikitext(title)
end

local function addRow(container, label, valueHtmlOrWikitext, isRawHtml)
	local labelDiv = container:tag('div')
	labelDiv:addClass('infobox-label')
	css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
	labelDiv:wikitext(label)

	local valueDiv = container:tag('div')
	valueDiv:addClass('infobox-value')
	css(valueDiv, 'padding:0.2em 0.4em;')

	if isRawHtml then
		valueDiv:node(mw.html.create('span'):wikitext(valueHtmlOrWikitext))
	else
		valueDiv:wikitext(valueHtmlOrWikitext)
	end
end

local function addFullRow(container, valueHtmlOrWikitext, cssText, isRawHtml, extraClass)
	local fullDiv = container:tag('div')
	css(fullDiv, 'grid-column: 1 / -1; ' .. (cssText or ''))
	if extraClass then
		fullDiv:addClass(extraClass)
	end

	if isRawHtml then
		fullDiv:wikitext(valueHtmlOrWikitext)
	else
		fullDiv:wikitext(valueHtmlOrWikitext)
	end
end

-- Helper function to extract model code from "Brand Model" format
-- If the accessory starts with the same brand as the CRT, strip it for cleaner display
local function getDisplayTextWithoutBrand(itemName, deviceBrand)
	if not itemName then return nil end
	if not deviceBrand then return itemName end
	
	-- Check if item name starts with the device brand followed by a space
	local brandPrefix = deviceBrand .. ' '
	if itemName:sub(1, #brandPrefix) == brandPrefix then
		-- Return just the model code portion
		return itemName:sub(#brandPrefix + 1)
	end
	
	-- Otherwise return the full name
	return itemName
end

-- Helper to query inputs/outputs/control ports for a specific location
local function queryIOByLocation(frame, fullPageName, ioType, location)
	local locationProp
	if ioType == 'input' then
		locationProp = 'Has input location'
	elseif ioType == 'output' then
		locationProp = 'Has output location'
	else -- control
		locationProp = 'Has control port location'
	end

	local locationFilter = ''
	if location then
		locationFilter = ' [[' .. locationProp .. '::' .. location .. ']]\n'
	end

	local signalProp, countProp, connectorProp, notesProp, labelProp, templateName
	local extraProps = ''

	if ioType == 'input' then
		signalProp = 'Has video signal format'
		countProp = 'Has input count'
		connectorProp = 'Has connector type'
		notesProp = 'Has input notes'
		labelProp = 'Has input label'
		templateName = 'CRT input row'
		extraProps = ' |?Has audio type=audio\n' ..
			' |?Has audio connector=audio_connector\n' ..
			' |?Has audio shared with=audio_shared\n'
	elseif ioType == 'output' then
		signalProp = 'Has output signal format'
		countProp = 'Has output count'
		connectorProp = 'Has output connector'
		notesProp = 'Has output notes'
		labelProp = 'Has output label'
		templateName = 'CRT output row'
	else -- control
		signalProp = 'Has control signal format'
		countProp = 'Has control port count'
		connectorProp = 'Has control port connector'
		notesProp = 'Has control port notes'
		labelProp = 'Has control port label'
		templateName = 'CRT control port row'
		extraProps = ' |?Has control port direction=direction\n'
	end

	local queryWikitext =
		'{{#ask:\n' ..
		' [[Has CRT model::' .. fullPageName .. ']]\n' ..
		' [[' .. signalProp .. '::+]]\n' ..
		locationFilter ..
		' |?' .. countProp .. '=count\n' ..
		' |?' .. signalProp .. '=signal\n' ..
		' |?' .. connectorProp .. '=connector\n' ..
		' |?' .. labelProp .. '=label\n' ..
		' |?' .. notesProp .. '=notes\n' ..
		extraProps ..
		' |format=template\n' ..
		' |template=' .. templateName .. '\n' ..
		' |named args=yes\n' ..
		' |link=none\n' ..
		'}}'

	local result = frame:preprocess(queryWikitext)
	return trim(result)
end

-- Helper to discover all distinct location values for a given IO type
-- Returns an ordered list of location strings, sorted with common locations first
local function discoverLocations(frame, fullPageName, ioType)
	local locationProp, signalProp
	if ioType == 'input' then
		locationProp = 'Has input location'
		signalProp = 'Has video signal format'
	elseif ioType == 'output' then
		locationProp = 'Has output location'
		signalProp = 'Has output signal format'
	else -- control
		locationProp = 'Has control port location'
		signalProp = 'Has control signal format'
	end

	local queryWikitext =
		'{{#ask:\n' ..
		' [[Has CRT model::' .. fullPageName .. ']]\n' ..
		' [[' .. signalProp .. '::+]]\n' ..
		' [[' .. locationProp .. '::+]]\n' ..
		' |?' .. locationProp .. '=location\n' ..
		' |format=broadtable\n' ..
		' |headers=hide\n' ..
		' |link=none\n' ..
		'}}'

	local result = frame:preprocess(queryWikitext)

	-- Parse distinct locations from broadtable rows
	local seen = {}
	local locations = {}

	-- Preferred display order for common locations
	local preferredOrder = {rear = 1, front = 2, side = 3, top = 4, bottom = 5, internal = 6}

	if result then
		for rowContent in result:gmatch('<tr[^>]*>(.-)</tr>') do
			local cells = {}
			for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
				local cleanContent = cellContent:gsub('<[^>]+>', '')
				table.insert(cells, trim(cleanContent))
			end
			-- cells[1] = subobject name, cells[2] = location value
			if #cells >= 2 and cells[2] then
				local loc = mw.text.trim(cells[2])
				-- Normalize to lowercase for deduplication
				local locLower = loc:lower()
				if locLower ~= '' and not seen[locLower] then
					seen[locLower] = true
					table.insert(locations, locLower)
				end
			end
		end
	end

	-- Sort: preferred locations first (in defined order), then remaining alphabetically
	table.sort(locations, function(a, b)
		local orderA = preferredOrder[a] or 1000
		local orderB = preferredOrder[b] or 1000
		if orderA ~= orderB then
			return orderA < orderB
		end
		return a < b
	end)

	return locations
end

-- Capitalize first letter of a location string for display
local function locationLabel(loc)
	if not loc or loc == '' then return '' end
	return loc:sub(1, 1):upper() .. loc:sub(2)
end

function p.infobox(frame)
	local parent = frame:getParent()
	local args = parent and parent.args or frame.args

	local title = mw.title.getCurrentTitle()
	local pageName = title.text
	local fullPageName = title.fullText
	local pageId = title.id or 0

	local brand = trim(args.brand)
	local model_code = trim(args.model_code)
	local variants = trim(args.variants)
	local market = trim(args.market)
	local series = trim(args.series)
	local image_main = trim(args.image_main)
	local model_3d = trim(args.model_3d)
	local crt_type = trim(args.crt_type)
	local form_factor = trim(args.form_factor)
	local built_in_devices = trim(args.built_in_devices)
	local screen_size_in = trim(args.screen_size_in)
	local aspect_ratio = trim(args.aspect_ratio)
	local tvl = trim(args.tvl)
	local chassis = trim(args.chassis)
	local chassis_family = trim(args.chassis_family)
	local remote_control = trim(args.remote_control)
	local height = trim(args.height)
	local width = trim(args.width)
	local depth = trim(args.depth)
	local weight = trim(args.weight)
	
	-- Power specifications (frequency and power consumption still from template args)
	local frequency = trim(args.frequency)
	local power_avg = trim(args.power_avg)
	local power_max = trim(args.power_max)
	
	-- TV system support
	local av_system = trim(args.av_system)
	local tuner_system = trim(args.tuner_system)
	
	-- Scan range
	local h_scan_min = trim(args.h_scan_min)
	local h_scan_max = trim(args.h_scan_max)
	local v_scan_min = trim(args.v_scan_min)
	local v_scan_max = trim(args.v_scan_max)
	
	-- Speakers
	local speaker_type = trim(args.speaker_type)
	local speaker_watts = trim(args.speaker_watts)
	local subwoofer = trim(args.subwoofer)
	
	-- Release year and MSRP
	local release_year = trim(args.release_year)
	local msrp = trim(args.msrp)
	
	-- Tube specifications
	local mask_type = trim(args.mask_type)
	local pitch = trim(args.pitch)
	local phosphor_type = trim(args.phosphor_type)
	local gun_type = trim(args.gun_type)
	local deflection_angle = trim(args.deflection_angle)
	local neck_diameter = trim(args.neck_diameter)
	local visible_w = trim(args.visible_w)
	local visible_h = trim(args.visible_h)
	local visible_diag = trim(args.visible_diag)

	-- Use frame:extensionTag to create a proper <style> tag that won't be escaped
	local styleTag = frame:extensionTag('templatestyles', '', {src = 'Template:Infobox styles.css'})

	local container = mw.html.create('div')
	container:addClass('infobox')
	container:addClass('infobox-crt-model')
	css(container, 'float:right; clear:right; margin:0 0 1em 1em; width:22em; font-size:88%; line-height:1.5em; display:grid; grid-template-columns:auto 1fr; gap:3px; padding:3px;')

	-- Title row (no background color, centered, bold)
	addFullRow(
		container,
		(brand or '') .. ' ' .. (model_code or ''),
		'text-align:center; font-size:125%; font-weight:bold; padding:0.5em;',
		false,
		'infobox-title'
	)

	-- Type badge (breadcrumb) — plain wikilinks avoid nofollow and external-link icon
	-- To enable pre-filtered type links later, add wiki domain to $wgNoFollowDomainExceptions
	local badgeWikitext = '[[CRT Search|CRT]]'
	if crt_type then
		badgeWikitext = badgeWikitext .. ' › [[CRT Search|' .. crt_type .. ']]'
	end
	addFullRow(container, badgeWikitext, '', false, 'infobox-type-badge')

	if image_main then
		local file = image_main:gsub('^File:', '')
		addFullRow(
			container,
			string.format('[[File:%s|frameless|300px]]', file),
			'text-align:center; padding:0;',
			false,
			'infobox-image'
		)
	end

	-- 3D model display (right below main photo)
	if model_3d then
		local file = model_3d:gsub('^File:', '')
		addFullRow(
			container,
			string.format('[[File:%s]]', file),
			'text-align:center; padding:0;',
			false,
			'infobox-3d-model'
		)
	end

	addSectionRow(container, 'Overview')

	-- Brand row using #formredlink (must preprocess)
	if brand then
		local brandWikitext = string.format(
			'{{#formredlink: target=%s |form=Manufacturer |existing page link text=%s }}',
			brand, brand
		)
		local brandHtml = frame:preprocess(brandWikitext)
		addRow(container, 'Brand', brandHtml, true)
	end

	if model_code then
		addRow(container, 'Model', model_code, false)
	else
		addRow(container, 'Model', '', false)
	end

	-- Regional variants row
	if variants then
		addRow(container, 'Variants', variants, false)
	end

	-- Market row
	if market then
		addRow(container, 'Market', market, false)
	end

	if series then
		local seriesWikitext = string.format(
			'{{#formredlink: target=%s |form=Series |existing page link text=%s }}',
			series, series
		)
		local seriesHtml = frame:preprocess(seriesWikitext)
		addRow(container, 'Series', seriesHtml, true)
	end

	-- Release year row
	if release_year then
		addRow(container, 'Released', release_year, false)
	end

	-- MSRP row
	if msrp then
		addRow(container, 'MSRP', msrp, false)
	end

	if crt_type then
		addRow(container, 'Type', crt_type, false)
	end

	-- Form factor row (only show if not "Standard")
	if form_factor and form_factor ~= 'Standard' then
		addRow(container, 'Form factor', form_factor, false)
	end

	-- Built-in devices row
	if built_in_devices then
		addRow(container, 'Built-in', built_in_devices, false)
	end

	if screen_size_in then
		addRow(container, 'Screen size', screen_size_in .. '″', false)
	end
	if aspect_ratio then
		addRow(container, 'Aspect ratio', aspect_ratio, false)
	end
	if tvl then
		addRow(container, 'TVL', tvl, false)
	end

	-- Chassis row: display both label chassis and family if available
	if chassis or chassis_family then
		local chassisDisplay = ''
		
		if chassis then
			local chassisWikitext = string.format(
				'{{#formredlink: target=%s |form=Chassis |existing page link text=%s }}',
				chassis, chassis
			)
			chassisDisplay = frame:preprocess(chassisWikitext)
		end
		
		if chassis_family then
			local familyWikitext = string.format(
				'{{#formredlink: target=%s |form=Chassis family |existing page link text=%s }}',
				chassis_family, chassis_family
			)
			local familyHtml = frame:preprocess(familyWikitext)
			
			if chassis then
				chassisDisplay = chassisDisplay .. '<br><small>Family: ' .. familyHtml .. '</small>'
			else
				chassisDisplay = familyHtml .. ' <small>(family)</small>'
			end
		end
		
		addRow(container, 'Chassis', chassisDisplay, true)
	end

	-- Remote control row: use Accessory form, strip brand prefix if it matches CRT brand
	if remote_control then
		local displayText = getDisplayTextWithoutBrand(remote_control, brand)
		local remoteWikitext = string.format(
			'{{#formredlink: target=%s |form=Accessory |existing page link text=%s }}',
			remote_control, displayText
		)
		local remoteHtml = frame:preprocess(remoteWikitext)
		addRow(container, 'Remote', remoteHtml, true)
	end

	-- ECID row
	local ecidWikitext = string.format('[{{fullurl:%s|curid=%d}} EC%d]', pageName, pageId, pageId)
	addRow(container, 'ECID', frame:preprocess(ecidWikitext), true)

	-- Tube section
	if mask_type or pitch or phosphor_type or gun_type or deflection_angle or neck_diameter or visible_w or visible_h or visible_diag then
		addSectionRow(container, 'Tube')
		if mask_type then addRow(container, 'Mask', mask_type, false) end
		if pitch then addRow(container, 'Pitch', pitch .. ' mm', false) end
		if phosphor_type then addRow(container, 'Phosphors', phosphor_type, false) end
		if gun_type then addRow(container, 'Gun type', gun_type, false) end
		if deflection_angle then addRow(container, 'Deflection', deflection_angle .. '°', false) end
		if neck_diameter then addRow(container, 'Neck', neck_diameter .. ' mm', false) end
		if visible_w and visible_h then
			addRow(container, 'Visible area', visible_w .. ' × ' .. visible_h .. ' mm', false)
		elseif visible_diag then
			addRow(container, 'Visible area', visible_diag .. '″ (diagonal)', false)
		end
		if visible_diag and visible_w and visible_h then
			addRow(container, 'Visible diag.', visible_diag .. '″', false)
		end
	end

	-- Dimensions section
	if height or width or depth or weight then
		addSectionRow(container, 'Dimensions')
		if height or width or depth then
			local h = height or '–'
			local w = width or '–'
			local d = depth or '–'
			addRow(container, 'H × W × D', string.format('%s × %s × %s', h, w, d), false)
		end
		if weight then
			local weightWikitext = '{{#show: ' .. fullPageName .. ' |?Weight # lb }} / {{#show: ' .. fullPageName .. ' |?Weight # kg }}'
			addRow(container, 'Weight', frame:preprocess(weightWikitext), true)
		end
	end

	-- Speakers section
	if speaker_type or speaker_watts or subwoofer then
		addSectionRow(container, 'Speakers')
		if speaker_type then addRow(container, 'Type', speaker_type, false) end
		if speaker_watts then addRow(container, 'Power', speaker_watts, false) end
		if subwoofer then addRow(container, 'Subwoofer', subwoofer, false) end
	end

	-- Power section
	local voltageQueryWikitext =
		'{{#ask:\n' ..
		' [[Has CRT model::' .. fullPageName .. ']]\n' ..
		' [[Voltage min::+]]\n' ..
		' |?Voltage min\n' ..
		' |?Voltage max\n' ..
		' |format=broadtable\n' ..
		' |headers=hide\n' ..
		' |link=none\n' ..
		' |sort=Voltage min\n' ..
		' |order=asc\n' ..
		'}}'
	local voltageResult = frame:preprocess(voltageQueryWikitext)
	local voltageRanges = {}
	if voltageResult then
		for rowContent in voltageResult:gmatch('<tr[^>]*>(.-)</tr>') do
			local cells = {}
			for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
				local cleanContent = cellContent:gsub('<[^>]+>', '')
				table.insert(cells, trim(cleanContent))
			end
			if #cells >= 3 then
				local vMin = tonumber(cells[2])
				local vMax = tonumber(cells[3])
				if vMin or vMax then
					table.insert(voltageRanges, {min = vMin, max = vMax})
				end
			end
		end
	end
	local hasVoltageRanges = #voltageRanges > 0

	if hasVoltageRanges or frequency or power_avg or power_max then
		addSectionRow(container, 'Power')
		if hasVoltageRanges then
			local rangeStrings = {}
			for _, range in ipairs(voltageRanges) do
				local rangeStr
				if range.min and range.max then
					if range.min == range.max then
						rangeStr = range.min .. 'V'
					else
						rangeStr = range.min .. '–' .. range.max .. 'V'
					end
				elseif range.min then
					rangeStr = range.min .. 'V'
				elseif range.max then
					rangeStr = range.max .. 'V'
				end
				if rangeStr then table.insert(rangeStrings, rangeStr) end
			end
			if #rangeStrings > 0 then
				addRow(container, 'Voltage', table.concat(rangeStrings, ' / '), false)
			end
		end
		if frequency then addRow(container, 'Frequency', frequency, false) end
		if power_avg or power_max then
			local powerDisplay
			if power_avg and power_max then
				powerDisplay = power_avg .. 'W / ' .. power_max .. 'W<br><small>(avg / max)</small>'
			elseif power_avg then
				powerDisplay = power_avg .. 'W <small>(avg)</small>'
			else
				powerDisplay = power_max .. 'W <small>(max)</small>'
			end
			addRow(container, 'Consumption', powerDisplay, true)
		end
	end

	-- TV Systems section
	if av_system or tuner_system then
		addSectionRow(container, 'TV Systems')
		if av_system then
			local systems = {}
			for system in av_system:gmatch('[^,]+') do
				local s = trim(system)
				if s then table.insert(systems, '[[' .. s .. '|' .. s .. ']]') end
			end
			if #systems > 0 then addRow(container, 'AV system', table.concat(systems, ', '), true) end
		end
		if tuner_system then
			local systems = {}
			for system in tuner_system:gmatch('[^,]+') do
				local s = trim(system)
				if s then table.insert(systems, '[[' .. s .. '|' .. s .. ']]') end
			end
			if #systems > 0 then addRow(container, 'Tuner', table.concat(systems, ', '), true) end
		end
	end

	-- Scan Range section
	if h_scan_min or h_scan_max or v_scan_min or v_scan_max then
		addSectionRow(container, 'Scan Range')
		if h_scan_min or h_scan_max then
			local hDisplay
			if h_scan_min and h_scan_max then
				hDisplay = (h_scan_min == h_scan_max) and (h_scan_min .. ' kHz') or (h_scan_min .. '–' .. h_scan_max .. ' kHz')
			else
				hDisplay = (h_scan_min or h_scan_max) .. ' kHz'
			end
			addRow(container, 'Horizontal', hDisplay, false)
		end
		if v_scan_min or v_scan_max then
			local vDisplay
			if v_scan_min and v_scan_max then
				vDisplay = (v_scan_min == v_scan_max) and (v_scan_min .. ' Hz') or (v_scan_min .. '–' .. v_scan_max .. ' Hz')
			else
				vDisplay = (v_scan_min or v_scan_max) .. ' Hz'
			end
			addRow(container, 'Vertical', vDisplay, false)
		end
	end

	-- Inputs, Outputs, and Control Ports sections
	local function renderIOSection(sectionTitle, ioType)
		local locations = discoverLocations(frame, fullPageName, ioType)
		local locationData = {}
		for _, loc in ipairs(locations) do
			local content = queryIOByLocation(frame, fullPageName, ioType, loc)
			if content and content ~= '' then
				table.insert(locationData, {location = loc, content = content})
			end
		end
		if #locationData > 0 then
			addSectionRow(container, sectionTitle)
			for _, data in ipairs(locationData) do
				local labelDiv = container:tag('div')
				labelDiv:addClass('infobox-label')
				css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
				labelDiv:wikitext(locationLabel(data.location))
				local valueDiv = container:tag('div')
				valueDiv:addClass('infobox-value')
				css(valueDiv, 'padding:0.2em 0.4em;')
				valueDiv:node(mw.html.create('span'):wikitext(data.content))
			end
		end
	end

	renderIOSection('Inputs', 'input')
	renderIOSection('Outputs', 'output')
	renderIOSection('Control Ports', 'control')

	-- Accessories section
	addSectionRow(container, 'Accessories')
	local addAccessoryUrl = mw.uri.fullUrl("Special:FormEdit", {form = "Accessory", ["Accessory[crt_models]"] = pageName})
	local accessoriesCountWikitext =
		'{{#ask:\n [[Category:Accessories]]\n [[Has related CRT model::' .. fullPageName .. ']]\n [[Has accessory type::!Remote control]]\n |format=count\n}}'
	local accessoryCount = frame:preprocess(accessoriesCountWikitext)
	local hasAccessories = accessoryCount and tonumber(trim(accessoryCount)) and tonumber(trim(accessoryCount)) > 0

	local accDiv = container:tag('div')
	css(accDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')

	if hasAccessories then
		local accessoriesQueryWikitext =
			'{{#ask:\n [[Category:Accessories]]\n [[Has related CRT model::' .. fullPageName .. ']]\n [[Has accessory type::!Remote control]]\n |?Has accessory type=type\n |?Has model code=model\n |format=broadtable\n |headers=hide\n |link=none\n}}'
		local accessoriesResult = frame:preprocess(accessoriesQueryWikitext)
		local accessoriesByType = {}
		local typeOrder = {}
		if accessoriesResult then
			for rowContent in accessoriesResult:gmatch('<tr[^>]*>(.-)</tr>') do
				local cells = {}
				for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
					table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
				end
				if #cells >= 2 then
					local accPageName = cells[1]
					local accType = cells[2] or 'Accessory'
					if accPageName then
						if not accessoriesByType[accType] then
							accessoriesByType[accType] = {}
							table.insert(typeOrder, accType)
						end
						table.insert(accessoriesByType[accType], {page = accPageName})
					end
				end
			end
		end
		local accGrid = accDiv:tag('div')
		css(accGrid, 'display:grid; grid-template-columns:auto 1fr; gap:3px;')
		for _, accType in ipairs(typeOrder) do
			local accs = accessoriesByType[accType]
			if accs and #accs > 0 then
				local accLinks = {}
				for _, acc in ipairs(accs) do table.insert(accLinks, '[[' .. acc.page .. ']]') end
				local labelDiv = accGrid:tag('div')
				labelDiv:addClass('infobox-label')
				css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
				labelDiv:wikitext(accType)
				local valueDiv = accGrid:tag('div')
				valueDiv:addClass('infobox-value')
				css(valueDiv, 'padding:0.2em 0.4em;')
				valueDiv:wikitext(table.concat(accLinks, '<br>'))
			end
		end
		local addAccDiv = mw.html.create('div')
		addAccDiv:css('margin-top', '0.5em'):css('text-align', 'center')
		addAccDiv:wikitext('[' .. tostring(addAccessoryUrl) .. ' <span class="infobox-button infobox-button-secondary">Add accessory</span>]')
		accDiv:node(addAccDiv)
	else
		local addAccDiv = mw.html.create('div')
		addAccDiv:css('text-align', 'center')
		addAccDiv:wikitext('[' .. tostring(addAccessoryUrl) .. ' <span class="infobox-button infobox-button-secondary">Add accessory</span>]')
		accDiv:node(addAccDiv)
	end

	-- Parts section
	addSectionRow(container, 'Parts')
	local addPartUrl = mw.uri.fullUrl("Special:FormEdit", {form = "Part", ["Part[crt_models]"] = pageName})
	local partsCountWikitext =
		'{{#ask:\n [[Category:Parts]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |format=count\n}}'
	local partsCount = frame:preprocess(partsCountWikitext)
	local hasParts = partsCount and tonumber(trim(partsCount)) and tonumber(trim(partsCount)) > 0
	local partsDiv = container:tag('div')
	css(partsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')

	if hasParts then
		local partsQueryWikitext =
			'{{#ask:\n [[Category:Parts]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |?Has part type=type\n |?Has part subtype=subtype\n |?Has model code=model\n |format=broadtable\n |headers=hide\n |link=none\n}}'
		local partsResult = frame:preprocess(partsQueryWikitext)
		local partsByType = {}
		local partTypeOrder = {}
		if partsResult then
			for rowContent in partsResult:gmatch('<tr[^>]*>(.-)</tr>') do
				local cells = {}
				for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
					table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
				end
				if #cells >= 2 then
					local partPageName = cells[1]
					local partType = cells[2] or 'Part'
					local partSubtype = cells[3]
					local groupKey = partType
					if partSubtype and partSubtype ~= '' then groupKey = partSubtype .. ' ' .. partType end
					if partPageName then
						if not partsByType[groupKey] then
							partsByType[groupKey] = {}
							table.insert(partTypeOrder, groupKey)
						end
						table.insert(partsByType[groupKey], {page = partPageName})
					end
				end
			end
		end
		local partsGrid = partsDiv:tag('div')
		css(partsGrid, 'display:grid; grid-template-columns:auto 1fr; gap:3px;')
		for _, partType in ipairs(partTypeOrder) do
			local parts = partsByType[partType]
			if parts and #parts > 0 then
				local partLinks = {}
				for _, part in ipairs(parts) do table.insert(partLinks, '[[' .. part.page .. ']]') end
				local labelDiv = partsGrid:tag('div')
				labelDiv:addClass('infobox-label')
				css(labelDiv, 'padding:0.2em 0.4em; font-weight:bold; text-align:left;')
				labelDiv:wikitext(partType)
				local valueDiv = partsGrid:tag('div')
				valueDiv:addClass('infobox-value')
				css(valueDiv, 'padding:0.2em 0.4em;')
				valueDiv:wikitext(table.concat(partLinks, '<br>'))
			end
		end
		local addPartDiv = mw.html.create('div')
		addPartDiv:css('margin-top', '0.5em'):css('text-align', 'center')
		addPartDiv:wikitext('[' .. tostring(addPartUrl) .. ' <span class="infobox-button infobox-button-secondary">Add part</span>]')
		partsDiv:node(addPartDiv)
	else
		local addPartDiv = mw.html.create('div')
		addPartDiv:css('text-align', 'center')
		addPartDiv:wikitext('[' .. tostring(addPartUrl) .. ' <span class="infobox-button infobox-button-secondary">Add part</span>]')
		partsDiv:node(addPartDiv)
	end

	-- Mods section
	local modsQueryWikitext =
		'{{#ask:\n [[Has CRT model::' .. fullPageName .. ']]\n [[Has mod URL::+]]\n |?Has mod type=type\n |?Has mod description=description\n |?Has mod feasible=feasible\n |?Has mod URL=url\n |format=broadtable\n |headers=hide\n |link=none\n}}'
	local modsResult = frame:preprocess(modsQueryWikitext)
	local mods = {}
	if modsResult then
		for rowContent in modsResult:gmatch('<tr[^>]*>(.-)</tr>') do
			local cells = {}
			for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
				table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
			end
			if #cells >= 3 then
				local modData = {type = nil, description = nil, feasible = nil, url = nil}
				for i = 2, #cells do
					local cell = cells[i]
					if cell then
						if cell:match('^https?://') then modData.url = cell
						elseif cell == 'Yes' or cell == 'No' then modData.feasible = cell
						elseif cell == 'RGB' or cell == 'S-video' or cell == 'Other' then modData.type = cell
						else modData.description = cell end
					end
				end
				if modData.url and modData.type then table.insert(mods, modData) end
			end
		end
	end
	if #mods > 0 then
		addSectionRow(container, 'Mods')
		local modsDiv = container:tag('div')
		css(modsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')
		local ul = modsDiv:tag('ul')
		ul:css('margin', '0'):css('padding-left', '1.5em')
		for _, mod in ipairs(mods) do
			local li = ul:tag('li')
			local displayText = mod.description or mod.type
			local isFeasible = mod.feasible ~= 'No'
			if isFeasible then
				li:wikitext('[' .. mod.url .. ' ' .. displayText .. ']')
			else
				local wrapper = li:tag('span')
				wrapper:addClass('mod-not-feasible')
				wrapper:wikitext('✗ [' .. mod.url .. ' ' .. displayText .. '] ')
				local notPossible = wrapper:tag('i')
				notPossible:css('font-size', '0.9em')
				notPossible:wikitext('(not possible)')
			end
		end
	end

	-- Documents section
	addSectionRow(container, 'Documents')
	local timestamp = os.time()
	math.randomseed(timestamp + pageId)
	local random = math.random(1000, 9999)
	local target = "Document:" .. timestamp .. "-" .. random
	local uploadUrl = mw.uri.fullUrl("Special:FormEdit", {form = "Document", target = target, ["Document[crt_models]"] = pageName})
	local documentsCountWikitext =
		'{{#ask:\n [[Category:Documents]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |format=count\n}}'
	local documentCount = frame:preprocess(documentsCountWikitext)
	local hasDocuments = documentCount and tonumber(trim(documentCount)) and tonumber(trim(documentCount)) > 0
	local docsDiv = container:tag('div')
	css(docsDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em;')

	if hasDocuments then
		local docsQueryWikitext =
			'{{#ask:\n [[Category:Documents]]\n [[Has related CRT model::' .. fullPageName .. ']]\n |?Has document type\n |?Has document URL\n |?Has document file\n |?Has document title\n |format=broadtable\n |headers=hide\n |link=none\n}}'
		local docsResult = frame:preprocess(docsQueryWikitext)
		local ul = mw.html.create('ul')
		ul:css('margin', '0'):css('padding-left', '1.5em')
		if docsResult then
			for rowContent in docsResult:gmatch('<tr[^>]*>(.-)</tr>') do
				local cells = {}
				for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
					table.insert(cells, cellContent:gsub('<[^>]+>', ''))
				end
				if #cells >= 2 then
					local docType = trim(cells[2])
					local docUrl = trim(cells[3])
					local docFile = trim(cells[4])
					local docTitle = trim(cells[5])
					local docPageName = trim(cells[1])
					if docPageName then
						local displayText = 'Document'
						if docTitle then displayText = docTitle
						elseif docType then displayText = docType end
						local li = ul:tag('li')
						li:wikitext('[[' .. docPageName .. '|' .. displayText .. ']]')
					end
				end
			end
		end
		docsDiv:node(ul)
		local uploadDiv = mw.html.create('div')
		uploadDiv:css('margin-top', '0.5em'):css('text-align', 'center')
		uploadDiv:wikitext('[' .. tostring(uploadUrl) .. ' <span class="infobox-button infobox-button-secondary">Add document</span>]')
		docsDiv:node(uploadDiv)
	else
		local uploadDiv = mw.html.create('div')
		uploadDiv:css('text-align', 'center')
		uploadDiv:wikitext('[' .. tostring(uploadUrl) .. ' <span class="infobox-button infobox-button-secondary">Add document</span>]')
		docsDiv:node(uploadDiv)
	end

	-- Links section
	local linksQueryWikitext =
		'{{#ask:\n [[CRT model::' .. fullPageName .. ']]\n |?Model link type\n |?Model link source\n |?Model link URL\n |format=broadtable\n |headers=hide\n |link=none\n}}'
	local linksResult = frame:preprocess(linksQueryWikitext)
	local linksByType = {Manufacturer = {}, Retailer = {}, Database = {}}
	local hasLinks = false
	if linksResult then
		for rowContent in linksResult:gmatch('<tr[^>]*>(.-)</tr>') do
			local cells = {}
			for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do
				table.insert(cells, trim(cellContent:gsub('<[^>]+>', '')))
			end
			if #cells >= 4 then
				local linkType = cells[2]
				local linkSource = cells[3]
				local linkUrl = cells[4]
				if linkType and linkSource and linkUrl and linkUrl ~= '' and linksByType[linkType] then
					table.insert(linksByType[linkType], {source = linkSource, url = linkUrl})
					hasLinks = true
				end
			end
		end
	end
	if hasLinks then
		addSectionRow(container, 'Archival Links')
		local linkTypeOrder = {'Database', 'Manufacturer', 'Retailer'}
		for _, linkType in ipairs(linkTypeOrder) do
			if #linksByType[linkType] > 0 then
				local linksHtml = {}
				for _, link in ipairs(linksByType[linkType]) do
					table.insert(linksHtml, '[' .. link.url .. ' ' .. link.source .. ']')
				end
				addRow(container, linkType, table.concat(linksHtml, '<br>'), true)
			end
		end
	end

	-- Contribute section
	addSectionRow(container, 'Contribute')
	local editUrl = mw.uri.fullUrl("Special:FormStart", {form = "CRT model", page_name = pageName})
	local editDiv = container:tag('div')
	css(editDiv, 'grid-column: 1 / -1; padding:0.2em 0.4em; text-align:center;')
	editDiv:wikitext('[' .. tostring(editUrl) .. ' <span class="infobox-button infobox-button-primary">Edit this data</span>]')

	return styleTag .. tostring(container)
end

return p
MediaWiki Appliance - Powered by TurnKey Linux