Module:DYK scrutiny
This module is used by Template:DYK scrutiny/helper to create the wikitext at Template:DYK scrutiny. An explanation of the templates' and module's purpose is at Template:DYK scrutiny/doc.
Usage
[edit]{{#invoke:DYK scrutiny|generatePings|page_name|heading=optional_heading}}
Parameters
[edit]- generatePings
-
|1=(required): Full page name of a queue or prep area (e.g.,Template:Did you know/Queue/1)|heading=(optional): Custom heading text for the output
Output
[edit]The module generates a syntax-highlighted wikitext box containing:
- Links to articles
- Links to nominations
- {{Reply to}} pings for the article creator and (if they are different editors) the nominator
The module cannot automatically detect reviewers and promoters due to the complexity of the DYK processes.
For implementation details, see the inline code comments in the module.
local p = {}
-- Each Nomination object corresponds to a single hook in a set
local Nomination = {}
Nomination.__index = Nomination
function Nomination.new()
local self = setmetatable({}, Nomination)
self.nomination = nil
self.articles = {}
self.editors = {}
self.roles = {}
return self
end
function Nomination:addArticle(page)
table.insert(self.articles, page)
end
function Nomination:setNomination(page)
self.nomination = page
end
function Nomination:addEditor(username)
-- Check if username is in editors list
for _, editor in ipairs(self.editors) do
if editor == username then
return -- Don't add a user multiple times
end
end
table.insert(self.editors, username)
end
function Nomination:setRole(username, role)
self.roles[username] = role
end
function Nomination:getRole(username)
return self.roles[username]
end
function Nomination:addRole(username, role)
-- Add the editor if they're not in the list
self:addEditor(username)
-- Set editor's role (creator, nominator, promoter, or blank)
self:setRole(username, role)
end
-- Helper function to extract base article name and nomination number
-- Strips "(2nd nomination)", "(3rd nomination)", etc. from article names
function p.parseArticleName(article)
local base, nomNum = article:match("^(.+)%s+%((%d+).. nomination%)$")
if base then
return base, tonumber(nomNum)
end
return article, 0 -- 0 is the original nomination (no suffix)
end
-- Compare article names with case-insensitive first letter
-- Wikipedia treats the first letter as case-insensitive: Apple == apple
function p.articleNamesMatch(name1, name2)
if not name1 or not name2 then
return false
end
-- Use mw.title.new to normalize (uppercases first letter automatically)
local title1 = mw.title.new(name1)
local title2 = mw.title.new(name2)
if not title1 or not title2 then
return false
end
-- Compare the prefixedText (normalized form)
return title1.prefixedText == title2.prefixedText
end
-- Parser for bulleted hooks
function p.parseHooks(pageContent)
local nominations = {}
local foundAnyList = false
-- Find the hooks section between <!--Hooks--> and <!--HooksEnd-->
local hooksStart = pageContent:find("<!%-%-Hooks%-%->")
local hooksEnd = pageContent:find("<!%-%-HooksEnd%-%->")
if not hooksStart or not hooksEnd then
return nil, "No list of hooks was found on the page."
end
local hooksSection = pageContent:sub(hooksStart, hooksEnd)
-- Split content into lines
for line in hooksSection:gmatch("[^\r\n]+") do
-- Check if line starts with asterisk (bullet point)
if line:match("^%*") then
foundAnyList = true
local nom = Nomination.new()
-- Find all bold wiki links in the line: '''[[...]]'''
-- Ignore non-bold links as they are not part of DYK scrutiny
local pos = 1
while pos <= #line do
-- Look for opening '''
local boldStart = line:find("'''", pos, true)
if not boldStart then
break
end
-- Look for [[ after the '''
local linkStart = line:find("(%[%[)", boldStart + 3)
if not linkStart then
pos = boldStart + 3
else
-- Find the closing ]] for this link
local linkEnd = line:find("(%]%])", linkStart + 2)
if not linkEnd then
pos = linkStart + 2
else
-- Check if there's a closing ''' after the ]]
local boldEnd = line:find("'''", linkEnd + 2, true)
if boldEnd and boldEnd == linkEnd + 2 then
-- Extract the link content between [[ and ]]
local linkContent = line:sub(linkStart + 2, linkEnd - 1)
-- Split on pipe to get article name (before pipe or whole thing)
local articleName = linkContent:match("^([^|]+)")
if articleName then
nom:addArticle(articleName)
end
pos = boldEnd + 3
else
pos = linkEnd + 2
end
end
end
end
-- Only add nomination if it has at least one article
if #nom.articles > 0 then
table.insert(nominations, nom)
end
end
end
if not foundAnyList then
return nil, "No list of hooks was found on the page."
end
if #nominations == 0 then
return nil, "No links found in hooks on the page."
end
return nominations
end
-- Parse credits section and build a mapping of articles to their credit info
function p.parseCreditsAndRoles(pageContent)
local creditsByArticle = {}
-- Find the credits div
local creditsStart = pageContent:find('<div id="credits">')
if not creditsStart then
creditsStart = pageContent:find('<div%s+id%s*=%s*"credits"%s*>')
end
if not creditsStart then
return creditsByArticle -- Return empty table if no credits section
end
local creditsEnd = pageContent:find("</div>", creditsStart)
if not creditsEnd then
creditsEnd = #pageContent
end
local creditsSection = pageContent:sub(creditsStart, creditsEnd)
-- Process {{DYKmake}} templates
for dykMake in creditsSection:gmatch("{{DYKmake[^}]*}}") do
-- Extract article name (parameter 1)
local articleName = dykMake:match("{{DYKmake|([^|]+)|")
if articleName then
articleName = articleName:match("^%s*(.-)%s*$")
end
-- Extract subpage
local subpage = dykMake:match("|%s*subpage%s*=%s*([^|}]+)")
if subpage then
subpage = subpage:match("^%s*(.-)%s*$")
elseif not subpage and articleName then
-- If no subpage parameter, use article name
subpage = articleName
end
-- Extract creator (parameter 2)
local creator = dykMake:match("{{DYKmake|[^|]+|([^|]+)")
if creator then
creator = creator:match("^%s*(.-)%s*$")
end
-- Skip placeholder credits with "Editor"
if creator == "Editor" then
creator = nil
end
-- Initialize article entry if needed
if articleName and not creditsByArticle[articleName] then
creditsByArticle[articleName] = {
subpage = nil,
creators = {},
nominators = {},
used = false
}
end
-- Add subpage (first one wins)
if articleName and subpage and not creditsByArticle[articleName].subpage then
creditsByArticle[articleName].subpage = subpage
end
-- Add creator (avoid duplicates)
if articleName and creator then
local isDuplicate = false
for _, existingCreator in ipairs(creditsByArticle[articleName].creators) do
if existingCreator == creator then
isDuplicate = true
break
end
end
if not isDuplicate then
table.insert(creditsByArticle[articleName].creators, creator)
end
end
end
-- Process {{DYKnom}} templates
for dykNom in creditsSection:gmatch("{{DYKnom[^}]*}}") do
-- Extract article name (parameter 1)
local articleName = dykNom:match("{{DYKnom|([^|]+)|")
if articleName then
articleName = articleName:match("^%s*(.-)%s*$")
end
-- Extract nominator (parameter 2)
local nominator = dykNom:match("{{DYKnom|[^|]+|([^|}]+)")
if nominator then
nominator = nominator:match("^%s*(.-)%s*$")
end
-- Skip placeholder credits with "Nominator"
if nominator == "Nominator" then
nominator = nil
end
-- Initialize article entry if needed
if articleName and not creditsByArticle[articleName] then
creditsByArticle[articleName] = {
subpage = articleName, -- Use article name as subpage default
creators = {},
nominators = {},
used = false
}
end
-- Add nominator (avoid duplicates)
if articleName and nominator then
local isDuplicate = false
for _, existingNom in ipairs(creditsByArticle[articleName].nominators) do
if existingNom == nominator then
isDuplicate = true
break
end
end
if not isDuplicate then
table.insert(creditsByArticle[articleName].nominators, nominator)
end
end
end
return creditsByArticle
end
-- Match nominations to credits (smart matching + fallback)
function p.matchCreditsToNominations(nominations, creditsByArticle)
local unmatchedNominations = {}
local unmatchedCredits = {}
-- First pass: Smart matching by article names
for _, nom in ipairs(nominations) do
local matched = false
local firstSubpage = nil
-- Check if any of this nomination's articles appear in credits
for _, article in ipairs(nom.articles) do
-- Try to find matching credit (case-insensitive first letter)
local matchingCredit = nil
for creditArticle, creditInfo in pairs(creditsByArticle) do
if p.articleNamesMatch(article, creditArticle) and not creditInfo.used then
matchingCredit = creditArticle
break
end
end
if matchingCredit then
local creditInfo = creditsByArticle[matchingCredit]
matched = true
-- Use first subpage we find
if not firstSubpage and creditInfo.subpage then
firstSubpage = creditInfo.subpage
end
-- Add all creators from this article's credits
for _, creator in ipairs(creditInfo.creators) do
nom:addRole(creator, "creator")
end
-- Add all nominators from this article's credits
for _, nominator in ipairs(creditInfo.nominators) do
nom:addRole(nominator, "nominator")
end
-- Mark this credit as used
creditInfo.used = true
end
end
if matched then
-- Set the nomination page using first subpage found
if firstSubpage then
nom:setNomination("Template:Did you know nominations/" .. firstSubpage)
end
else
-- No match found, add to unmatched list
table.insert(unmatchedNominations, nom)
end
end
-- Collect unused credits (in order they appear)
-- We need to preserve order, so iterate through credits by article names in appearance order
for article, creditInfo in pairs(creditsByArticle) do
if not creditInfo.used then
table.insert(unmatchedCredits, {article = article, info = creditInfo})
end
end
-- Second pass: Fallback positional matching
for i, nom in ipairs(unmatchedNominations) do
if unmatchedCredits[i] then
local creditInfo = unmatchedCredits[i].info
-- Set nomination page
if creditInfo.subpage then
nom:setNomination("Template:Did you know nominations/" .. creditInfo.subpage)
end
-- Add creators
for _, creator in ipairs(creditInfo.creators) do
nom:addRole(creator, "creator")
end
-- Add nominators
for _, nominator in ipairs(creditInfo.nominators) do
nom:addRole(nominator, "nominator")
end
end
end
end
-- Month name from number
function p.getMonthName(monthNum)
local months = {
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
}
return months[monthNum]
end
-- Get previous month/year
function p.getPreviousMonth(year, month)
if month == 1 then
return year - 1, 12
else
return year, month - 1
end
end
-- Find promoter from JSON data
function p.findPromoter(jsonData, subpage)
if not jsonData then
return nil
end
for promoter, nominations in pairs(jsonData) do
if nominations[subpage] then
return promoter
end
end
return nil
end
-- Parse promoters from GalliumBot JSON files
function p.parsePromoters(nominations)
-- Get current date
local currentYear = tonumber(os.date("%Y"))
local currentMonth = tonumber(os.date("%m"))
-- Get previous two months
local prevYear, prevMonth = p.getPreviousMonth(currentYear, currentMonth)
local prev2Year, prev2Month = p.getPreviousMonth(prevYear, prevMonth)
-- Build page names for JSON files
local currentMonthName = p.getMonthName(currentMonth)
local prevMonthName = p.getMonthName(prevMonth)
local prev2MonthName = p.getMonthName(prev2Month)
local currentJsonPage = string.format("User:GalliumBot/proctor/open/%s %d.json", currentMonthName, currentYear)
local prevJsonPage = string.format("User:GalliumBot/proctor/open/%s %d.json", prevMonthName, prevYear)
local prev2JsonPage = string.format("User:GalliumBot/proctor/open/%s %d.json", prev2MonthName, prev2Year)
-- Try to load JSON data
local currentData = nil
local prevData = nil
local prev2Data = nil
pcall(
function()
currentData = mw.loadJsonData(currentJsonPage)
end
)
pcall(
function()
prevData = mw.loadJsonData(prevJsonPage)
end
)
pcall(
function()
prev2Data = mw.loadJsonData(prev2JsonPage)
end
)
-- For each nomination, try to find promoter
for _, nom in ipairs(nominations) do
if nom.nomination then
-- Extract subpage from nomination path
local subpage = nom.nomination:match("Template:Did you know nominations/(.+)$")
if subpage then
-- Try current month first
local promoter = p.findPromoter(currentData, subpage)
-- Try previous month
if not promoter then
promoter = p.findPromoter(prevData, subpage)
end
-- If still no, try two months back
-- Some of these seem to be placed a ways back
if not promoter then
promoter = p.findPromoter(prev2Data, subpage)
end
-- If found, assign promoter role
if promoter then
nom:addRole(promoter, "promoter")
end
end
end
end
end
-- Extract usernames from nomination page signature links
function p.parseEditors(pageContent, nomination)
if not pageContent then
return
end
local pos = 1
while pos <= #pageContent do
-- Look for [[User: pattern
local userStart = pageContent:find("%[%[User:", pos)
if not userStart then
break
end
-- Username starts right after "[[User:"
local usernameStart = userStart + 7 -- Length of "[[User:"
-- Find the end of the username (|, #, or ]])
local pipePos = pageContent:find("|", usernameStart, true)
local bracketPos = pageContent:find("]]", usernameStart, true)
-- Find # that is NOT preceded by & (HTML entity vs. anchor link)
local hashPos = nil
local searchPos = usernameStart
while searchPos <= #pageContent do
local nextHash = pageContent:find("#", searchPos, true)
if not nextHash then
break
end
-- If preceded by &
if nextHash > 1 and pageContent:sub(nextHash - 1, nextHash - 1) == "&" then
-- Skip the HTML entity
searchPos = nextHash + 1
else
-- This # is a section link to the user's page,
-- probably for the promoter, possibly a custom signature
hashPos = nextHash
break
end
end
-- Find the first delimiter
local endPos = bracketPos
if pipePos and (not endPos or pipePos < endPos) then
endPos = pipePos
end
if hashPos and (not endPos or hashPos < endPos) then
endPos = hashPos
end
if endPos then
local username = pageContent:sub(usernameStart, endPos - 1)
username = username:match("^%s*(.-)%s*$")
if username and username ~= "" then
nomination:addEditor(username)
end
-- Step past the delimiter to ensure progress
pos = endPos + 2
else
-- Ensure progress on bogus wikilinks
pos = usernameStart + 1
end
end
end
-- Combine hooks and credits, then fetch editors and promoters
function p.parseAll(pageContent)
local nominations, err = p.parseHooks(pageContent)
if err then
return nil, err
end
-- Parse credits into article-based mapping
local creditsByArticle = p.parseCreditsAndRoles(pageContent)
-- Match credits to nominations (smart + fallback)
p.matchCreditsToNominations(nominations, creditsByArticle)
-- Promoters from GalliumBot JSON
p.parsePromoters(nominations)
-- Fetch editors from nomination pages and
-- handle numbered nominations
for _, nom in ipairs(nominations) do
if nom.nomination then
local subpage = nom.nomination:match("Template:Did you know nominations/(.+)$")
if subpage then
-- Parse the subpage to get base name
local baseName = p.parseArticleName(subpage)
-- Try to get the nomination page (use base name for lookup)
local nomPageName = "Template:Did you know nominations/" .. baseName
local title = mw.title.new(nomPageName)
if title and title.exists then
local nominationContent = title:getContent()
p.parseEditors(nominationContent, nom)
end
end
end
end
return nominations
end
-- Format output function
function p.formatOutput(nominations, heading)
-- Group nominations by article display string
local articleGroups = {}
local orderCounter = 0
for _, nom in ipairs(nominations) do
if nom.nomination then
local subpage = nom.nomination:match("Template:Did you know nominations/(.+)$")
if subpage then
-- Get base name and nomination number from subpage
local baseName, nomNum = p.parseArticleName(subpage)
-- Build display string with all articles
local displayArticles = ""
if #nom.articles > 0 then
local articleLinks = {}
for _, article in ipairs(nom.articles) do
local baseArticle = p.parseArticleName(article)
table.insert(articleLinks, "[[" .. baseArticle .. "]]")
end
displayArticles = table.concat(articleLinks, ", ")
else
-- Fallback to subpage base name if no articles
displayArticles = "[[" .. baseName .. "]]"
end
-- Initialize group if needed
if not articleGroups[displayArticles] then
orderCounter = orderCounter + 1
articleGroups[displayArticles] = {
subpage = subpage,
nomNum = nomNum,
users = {},
order = orderCounter
}
end
-- Update to use the highest nomination number subpage
if nomNum > articleGroups[displayArticles].nomNum then
articleGroups[displayArticles].subpage = subpage
articleGroups[displayArticles].nomNum = nomNum
end
-- Add all editors with their roles
for _, editor in ipairs(nom.editors) do
if not articleGroups[displayArticles].users[editor] then
articleGroups[displayArticles].users[editor] = {role = nom:getRole(editor)}
end
end
end
end
end
-- Sort by order in queue
local sortedArticles = {}
for displayArticles, data in pairs(articleGroups) do
table.insert(sortedArticles, {article = displayArticles, data = data})
end
table.sort(
sortedArticles,
function(a, b)
return a.data.order < b.data.order
end
)
local output = {}
-- Add specified heading if provided
if heading and heading ~= "" then
table.insert(output, "==" .. heading .. "==")
table.insert(output, "")
end
-- Generate entry for each article
for _, item in ipairs(sortedArticles) do
local article = item.article
local data = item.data
-- Create heading with link (article already contains [[ ]] formatting)
local headingLine =
"===" .. article .. " ([[Template:Did you know nominations/" .. data.subpage .. "|nom]]) ==="
table.insert(output, headingLine)
-- Fill Template:Reply to with users and role comments
-- These comments have no effect on pings and are not visible
-- to the pinged editors.
local userEntries = {}
for user, userData in pairs(data.users) do
table.insert(userEntries, {name = user, role = userData.role})
end
-- Sort by role priority first, then alphabetically by name
local rolePriority = {promoter = 1, creator = 2, nominator = 3}
table.sort(
userEntries,
function(a, b)
local aPriority = (a.role and rolePriority[a.role]) or 4
local bPriority = (b.role and rolePriority[b.role]) or 4
if aPriority ~= bPriority then
return aPriority < bPriority
else
return a.name < b.name
end
end
)
local userStrings = {}
for _, entry in ipairs(userEntries) do
local userString = entry.name
-- Only add role comment for the defined roles:
if entry.role == "promoter" or entry.role == "creator" or entry.role == "nominator" then
userString = userString .. "<!--" .. entry.role .. "-->"
end
table.insert(userStrings, userString)
end
local replyLine = "{{reply to|" .. table.concat(userStrings, "|") .. "}} ~~~~"
table.insert(output, replyLine)
table.insert(output, "") -- Blank line between entries
end
return table.concat(output, "\n")
end
-- Main entry point for Scribunto
function p.generatePings(frame)
local pageName = frame.args[1] or frame:getParent().args[1]
local heading = frame.args.heading or frame:getParent().args.heading or ""
if not pageName then
return "Error: No page name provided"
end
-- Get page content
local title = mw.title.new(pageName)
if not title or not title.exists then
return "Error: Page does not exist"
end
local pageContent = title:getContent()
local nominations, err = p.parseAll(pageContent)
if err then
return err
end
-- Format output
local formattedOutput = p.formatOutput(nominations, heading)
-- Return in syntaxhighlight box with copy button
return frame:extensionTag("syntaxhighlight", formattedOutput, {lang = "wikitext", copy = "1"})
end
return p