Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

Jump to content

Module:DYK scrutiny

From Wikipedia, the free encyclopedia

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