Module:SMWUtils: Difference between revisions
Rewrite to use mw.smw.ask() now that Semantic Scribunto is installed (via update-page on MediaWiki MCP Server) |
Fix deduplication: key on printout columns instead of subject (subobject subjects are unique per-input) (via update-page on MediaWiki MCP Server) |
||
| (6 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- Module:SMWUtils | -- Module:SMWUtils | ||
-- Utility functions for Semantic MediaWiki queries | -- Utility functions for Semantic MediaWiki queries | ||
-- | -- Uses frame:preprocess() for #ask queries (supports property chains) | ||
local p = {} | local p = {} | ||
| Line 13: | Line 13: | ||
end | end | ||
--- Find all results where a property equals its minimum value | -- Parse an HTML table from #ask broadtable format into rows of cells | ||
-- @param frame | -- Filters out the "further results" row | ||
-- conditions: SMW | local function parseHtmlTable(html) | ||
-- minProperty: Property to | local rows = {} | ||
-- printouts: Comma-separated | if not html then return rows end | ||
-- | |||
-- | for rowContent in html:gmatch('<tr[^>]*>(.-)</tr>') do | ||
-- | -- Skip "further results" rows | ||
-- | if not rowContent:match('smw%-broadtable%-furtherresults') then | ||
local cells = {} | |||
for cellContent in rowContent:gmatch('<td[^>]*>(.-)</td>') do | |||
-- Keep HTML intact for links | |||
table.insert(cells, trim(cellContent) or '') | |||
end | |||
if #cells > 0 then | |||
table.insert(rows, cells) | |||
end | |||
end | |||
end | |||
return rows | |||
end | |||
-- Extract plain text from HTML (for numeric comparison) | |||
local function stripHtml(s) | |||
if not s then return nil end | |||
return trim(s:gsub('<[^>]+>', ''):gsub('&[^;]+;', '')) | |||
end | |||
-- Deduplicate rows by printout columns, keeping the first occurrence | |||
-- Uses stripped text of printout columns (2..n+1) as the dedup key, | |||
-- since subobject queries produce unique subjects (e.g. Page#sub1, Page#sub2) | |||
-- but identical printout values for the same parent page | |||
local function deduplicateRows(rows, numPrintouts) | |||
local seen = {} | |||
local deduped = {} | |||
for _, row in ipairs(rows) do | |||
local keyParts = {} | |||
for j = 2, numPrintouts + 1 do | |||
table.insert(keyParts, stripHtml(row[j]) or '') | |||
end | |||
local key = table.concat(keyParts, '|') | |||
if not seen[key] then | |||
seen[key] = true | |||
table.insert(deduped, row) | |||
end | |||
end | |||
return deduped | |||
end | |||
--- Debug function to see raw query output and parsing | |||
function p.debug(frame) | |||
local args = frame.args | |||
local conditions = trim(args.conditions) or '' | |||
local minProperty = trim(args.minProperty) or '' | |||
local printouts = trim(args.printouts) or '' | |||
-- Build printout list | |||
local printoutProps = {} | |||
local printoutQuery = {} | |||
for prop in printouts:gmatch('[^,]+') do | |||
local propTrimmed = trim(prop) | |||
table.insert(printoutProps, propTrimmed) | |||
table.insert(printoutQuery, '|?' .. propTrimmed) | |||
end | |||
-- Add minProperty to query | |||
if minProperty ~= '' then | |||
table.insert(printoutQuery, '|?' .. minProperty .. '=#') | |||
end | |||
local queryWikitext = '{{#ask:' .. conditions .. '\n' .. | |||
table.concat(printoutQuery, '\n') .. '\n' .. | |||
'|sort=' .. minProperty .. '\n' .. | |||
'|order=asc\n' .. | |||
'|limit=5\n' .. | |||
'|format=broadtable\n' .. | |||
'|headers=hide\n' .. | |||
'|link=all\n' .. | |||
'}}' | |||
local result = frame:preprocess(queryWikitext) | |||
local rows = parseHtmlTable(result) | |||
local output = {} | |||
table.insert(output, "'''Query wikitext:'''\n<pre>" .. mw.text.nowiki(queryWikitext) .. "</pre>\n\n") | |||
table.insert(output, "'''Raw HTML result:'''\n<pre>" .. mw.text.nowiki(result):sub(1, 2000) .. "</pre>\n\n") | |||
table.insert(output, "'''Parsed rows (" .. #rows .. "):'''\n\n") | |||
for i, row in ipairs(rows) do | |||
table.insert(output, "'''Row " .. i .. " (" .. #row .. " cells):'''\n") | |||
for j, cell in ipairs(row) do | |||
local stripped = stripHtml(cell) or "(nil)" | |||
table.insert(output, "* Cell " .. j .. ": <code>" .. mw.text.nowiki(cell):sub(1, 100) .. "</code> → stripped: <code>" .. stripped .. "</code>\n") | |||
end | |||
table.insert(output, "\n") | |||
end | |||
-- Show what we'd calculate as min (corrected: +2 for subject column) | |||
if #rows > 0 then | |||
local minValueCol = #printoutProps + 2 -- +1 for subject column, +1 for minProperty column | |||
table.insert(output, "'''Printout count:''' " .. #printoutProps .. "\n") | |||
table.insert(output, "'''Min value column index:''' " .. minValueCol .. " (printouts + 2 for subject col)\n") | |||
if rows[1][minValueCol] then | |||
local minText = stripHtml(rows[1][minValueCol]) | |||
local minNum = tonumber(minText) | |||
table.insert(output, "'''First row min column value:''' " .. (minText or "nil") .. " → tonumber: " .. tostring(minNum) .. "\n") | |||
else | |||
table.insert(output, "'''First row min column:''' (column doesn't exist)\n") | |||
end | |||
end | |||
return table.concat(output) | |||
end | |||
--- Find all results where a property equals its minimum value | |||
-- @param frame with args: | |||
-- conditions: SMW conditions (e.g., "[[Has video signal format::Component (YPbPr)]]") | |||
-- minProperty: Property to minimize (e.g., "Has CRT model.Has screen size inches") | |||
-- printouts: Comma-separated properties to display | |||
-- headers: Comma-separated header names | |||
-- mainlabel: Optional header for the subject/page name column | |||
-- format: table, ul, ol (default: table) | |||
-- intro: Custom intro text | |||
function p.findAllWithMin(frame) | function p.findAllWithMin(frame) | ||
local args = frame.args | local args = frame.args | ||
| Line 30: | Line 144: | ||
local headers = trim(args.headers) or '' | local headers = trim(args.headers) or '' | ||
local intro = trim(args.intro) | local intro = trim(args.intro) | ||
local mainlabel = trim(args.mainlabel) | |||
if not minProperty then | if not minProperty then | ||
| Line 35: | Line 150: | ||
end | end | ||
-- Build | -- Build printout list | ||
local | local printoutProps = {} | ||
local printoutQuery = {} | |||
for prop in printouts:gmatch('[^,]+') do | for prop in printouts:gmatch('[^,]+') do | ||
table.insert( | local propTrimmed = trim(prop) | ||
table.insert(printoutProps, propTrimmed) | |||
table.insert(printoutQuery, '|?' .. propTrimmed) | |||
end | end | ||
-- | -- Add minProperty to query | ||
table.insert( | table.insert(printoutQuery, '|?' .. minProperty .. '=#') | ||
-- Query all results sorted by minProperty ascending | |||
local queryWikitext = '{{#ask:' .. conditions .. '\n' .. | |||
table.concat(printoutQuery, '\n') .. '\n' .. | |||
'|sort=' .. minProperty .. '\n' .. | |||
'|order=asc\n' .. | |||
'|limit=500\n' .. | |||
'|format=broadtable\n' .. | |||
'|headers=hide\n' .. | |||
'|link=all\n' .. | |||
'}}' | |||
local result = frame:preprocess(queryWikitext) | |||
local | local rows = parseHtmlTable(result) | ||
local | |||
if | if #rows == 0 then | ||
return "No results found." | return "No results found." | ||
end | end | ||
-- | -- broadtable format: Column 1 = subject, Columns 2..n+1 = printouts, Column n+2 = minProperty | ||
local | -- So minValueCol = #printoutProps + 2 | ||
local minValueCol = #printoutProps + 2 | |||
local minValueText = stripHtml(rows[1][minValueCol]) | |||
local minValue = tonumber(minValueText) | |||
if minValue | if not minValue then | ||
return "Could not determine minimum value." | return "Could not determine minimum value. Column " .. minValueCol .. ", raw: " .. tostring(rows[1][minValueCol]) .. ", stripped: " .. tostring(minValueText) | ||
end | end | ||
-- Filter | -- Filter to only rows matching the minimum | ||
local filtered = {} | local filtered = {} | ||
for _, row in ipairs( | for _, row in ipairs(rows) do | ||
local val = row[ | local val = tonumber(stripHtml(row[minValueCol])) | ||
if val | if val == minValue then | ||
table.insert(filtered, row) | table.insert(filtered, row) | ||
end | end | ||
end | end | ||
-- Build | -- Deduplicate (multiple subobjects on same page produce duplicate rows) | ||
filtered = deduplicateRows(filtered, #printoutProps) | |||
-- Build headers list | |||
local headerList = {} | local headerList = {} | ||
for h in headers:gmatch('[^,]+') do | for h in headers:gmatch('[^,]+') do | ||
table.insert(headerList, trim(h)) | table.insert(headerList, trim(h)) | ||
end | end | ||
local introText = intro or ("Smallest screen size with this input: '''" .. minValue .. "\"'''") | local introText = intro or ("Smallest screen size with this input: '''" .. minValue .. "\"'''") | ||
if format == ' | if format == 'ul' then | ||
return p. | return p._formatList(filtered, printoutProps, headerList, 'ul', introText, mainlabel) | ||
elseif | elseif format == 'ol' then | ||
return p._formatList(filtered, printoutProps, | return p._formatList(filtered, printoutProps, headerList, 'ol', introText, mainlabel) | ||
else | else | ||
return p._formatTable(filtered, printoutProps, headerList, introText) | return p._formatTable(filtered, printoutProps, headerList, introText, mainlabel) | ||
end | end | ||
end | end | ||
| Line 109: | Line 227: | ||
local headers = trim(args.headers) or '' | local headers = trim(args.headers) or '' | ||
local intro = trim(args.intro) | local intro = trim(args.intro) | ||
local mainlabel = trim(args.mainlabel) | |||
if not maxProperty then | if not maxProperty then | ||
| Line 114: | Line 233: | ||
end | end | ||
-- Build | -- Build printout list | ||
local | local printoutProps = {} | ||
local printoutQuery = {} | |||
for prop in printouts:gmatch('[^,]+') do | for prop in printouts:gmatch('[^,]+') do | ||
table.insert( | local propTrimmed = trim(prop) | ||
table.insert(printoutProps, propTrimmed) | |||
table.insert(printoutQuery, '|?' .. propTrimmed) | |||
end | end | ||
table.insert( | table.insert(printoutQuery, '|?' .. maxProperty .. '=#') | ||
-- Query all results sorted by maxProperty descending | |||
local queryWikitext = '{{#ask:' .. conditions .. '\n' .. | |||
table.concat(printoutQuery, '\n') .. '\n' .. | |||
'|sort=' .. maxProperty .. '\n' .. | |||
'|order=desc\n' .. | |||
'|limit=500\n' .. | |||
'|format=broadtable\n' .. | |||
'|headers=hide\n' .. | |||
'|link=all\n' .. | |||
'}}' | |||
local result = frame:preprocess(queryWikitext) | |||
local | local rows = parseHtmlTable(result) | ||
local | |||
if | if #rows == 0 then | ||
return "No results found." | return "No results found." | ||
end | end | ||
-- | -- broadtable format: Column 1 = subject, Columns 2..n+1 = printouts, Column n+2 = maxProperty | ||
local | local maxValueCol = #printoutProps + 2 | ||
local maxValueText = stripHtml(rows[1][maxValueCol]) | |||
local maxValue = tonumber(maxValueText) | |||
if maxValue | if not maxValue then | ||
return "Could not determine maximum value." | return "Could not determine maximum value. Column " .. maxValueCol .. ", raw: " .. tostring(rows[1][maxValueCol]) .. ", stripped: " .. tostring(maxValueText) | ||
end | end | ||
-- Filter | -- Filter to only rows matching the maximum | ||
local filtered = {} | local filtered = {} | ||
for _, row in ipairs( | for _, row in ipairs(rows) do | ||
local val = row[ | local val = tonumber(stripHtml(row[maxValueCol])) | ||
if val | if val == maxValue then | ||
table.insert(filtered, row) | table.insert(filtered, row) | ||
end | end | ||
end | end | ||
-- Build | -- Deduplicate (multiple subobjects on same page produce duplicate rows) | ||
filtered = deduplicateRows(filtered, #printoutProps) | |||
-- Build headers list | |||
local headerList = {} | local headerList = {} | ||
for h in headers:gmatch('[^,]+') do | for h in headers:gmatch('[^,]+') do | ||
table.insert(headerList, trim(h)) | table.insert(headerList, trim(h)) | ||
end | end | ||
local introText = intro or ("Largest screen size with this input: '''" .. maxValue .. "\"'''") | local introText = intro or ("Largest screen size with this input: '''" .. maxValue .. "\"'''") | ||
if format == ' | if format == 'ul' then | ||
return p. | return p._formatList(filtered, printoutProps, headerList, 'ul', introText, mainlabel) | ||
elseif | elseif format == 'ol' then | ||
return p._formatList(filtered, printoutProps, | return p._formatList(filtered, printoutProps, headerList, 'ol', introText, mainlabel) | ||
else | else | ||
return p._formatTable(filtered, printoutProps, headerList, introText) | return p._formatTable(filtered, printoutProps, headerList, introText, mainlabel) | ||
end | end | ||
end | end | ||
-- | -- Format results as a wikitable | ||
function p._formatTable( | -- Note: row[1] is subject, row[2..n+1] are printouts, row[n+2] is min/max value | ||
function p._formatTable(rows, printouts, headers, introText, mainlabel) | |||
local output = {} | local output = {} | ||
| Line 187: | Line 309: | ||
-- Header row | -- Header row | ||
table.insert(output, '|-\n! #') | table.insert(output, '|-\n! #') | ||
if mainlabel then | |||
table.insert(output, ' !! ' .. mainlabel) | |||
end | |||
for i, prop in ipairs(printouts) do | for i, prop in ipairs(printouts) do | ||
local headerText = headers[i] or prop:gsub('.*%.', ''):gsub('Has ', '') | local headerText = headers[i] or prop:gsub('.*%.', ''):gsub('Has ', '') | ||
| Line 193: | Line 318: | ||
table.insert(output, '\n') | table.insert(output, '\n') | ||
-- Data rows | -- Data rows: printout columns start at index 2 (after subject column) | ||
for i, row in ipairs( | for i, row in ipairs(rows) do | ||
local tr = '|-\n| ' .. tostring(i) | local tr = '|-\n| ' .. tostring(i) | ||
if mainlabel then | |||
tr = tr .. ' || ' .. (row[1] or '') | |||
end | |||
for j = 1, #printouts do | |||
-- Printout j is in column j+1 (offset by subject column) | |||
tr = tr .. ' || ' .. (row[j + 1] or '') | |||
tr = tr .. ' || ' .. | |||
end | end | ||
table.insert(output, tr .. '\n') | table.insert(output, tr .. '\n') | ||
| Line 230: | Line 336: | ||
end | end | ||
-- | -- Format results as a list | ||
function p._formatList( | function p._formatList(rows, printouts, headers, listType, introText, mainlabel) | ||
local output = {} | local output = {} | ||
local bullet = listType == 'ol' and '#' or '*' | local bullet = listType == 'ol' and '#' or '*' | ||
| Line 237: | Line 343: | ||
table.insert(output, introText .. "\n\n") | table.insert(output, introText .. "\n\n") | ||
for _, row in ipairs( | for _, row in ipairs(rows) do | ||
local parts = {} | local parts = {} | ||
if mainlabel then | |||
local | local subject = row[1] | ||
if | if subject and subject ~= '' then | ||
table.insert(parts, subject) | |||
end | |||
end | |||
for j = 1, #printouts do | |||
-- Printout j is in column j+1 (offset by subject column) | |||
local cell = row[j + 1] | |||
if cell and cell ~= '' then | |||
table.insert(parts, cell) | |||
end | end | ||
end | end | ||