Module:DynamicYSWSList/sandbox: Difference between revisions

Created page with " -- Module:DynamicYSWSList -- Public Hack Club events API only. local p = {} local API_URL = 'https://hackclub.com/api/v1/events' local function str(value) if value == nil then return '' end if type(value) ~= 'string' and type(value) ~= 'number' then return '' end return mw.text.trim(tostring(value)) end local function firstNonEmpty(...) for i = 1, select('#', ...) do local value = str(select(i, ...)) if value ~..."
 
No edit summary
 
Line 1: Line 1:


-- Module:DynamicYSWSList
-- Module:DynamicYSWSList
-- Public Hack Club events API only.
-- Public Hack Club YSWS directory
-- Data source: https://hackclub.com/api/v1/events
--
-- Generates a sortable MediaWiki table.
-- Search and status updates are handled by personal JS or a gadget.


local p = {}
local p = {}


local API_URL = 'https://hackclub.com/api/v1/events'
local API_URL = 'https://hackclub.com/api/v1/events'
local MONTHS = {
    'January', 'February', 'March',
    'April', 'May', 'June',
    'July', 'August', 'September',
    'October', 'November', 'December'
}
-- ============================================================
-- Utility functions
-- ============================================================


local function str(value)
local function str(value)
Line 11: Line 26:
         return ''
         return ''
     end
     end
     if type(value) ~= 'string' and type(value) ~= 'number' then
 
     if type(value) ~= 'string'
        and type(value) ~= 'number' then
         return ''
         return ''
     end
     end
     return mw.text.trim(tostring(value))
     return mw.text.trim(tostring(value))
end
end
Line 20: Line 38:
     for i = 1, select('#', ...) do
     for i = 1, select('#', ...) do
         local value = str(select(i, ...))
         local value = str(select(i, ...))
         if value ~= '' then
         if value ~= '' then
             return value
             return value
         end
         end
     end
     end
     return ''
     return ''
end
-- Add literal text safely to an mw.html element.
-- External data must not be interpreted as wiki markup.
local function addText(element, value)
    element:wikitext(
        mw.text.nowiki(tostring(value or ''))
    )
    return element
end
end


local function validUrl(url)
local function validUrl(url)
     url = str(url)
     url = str(url)
     if url:match('^https?://[^%s<>]+$') then
 
     if url:match('^https?://[^%s<>"\']+$') then
         return url
         return url
     end
     end
     return nil
     return nil
end
end
-- ============================================================
-- Date handling
-- ============================================================


local function normalizeDate(value)
local function normalizeDate(value)
     value = str(value)
     value = str(value)


     -- Accept ISO dates and ISO timestamps.
     -- Supports ISO dates and ISO timestamps.
     local year, month, day =
     local year, month, day =
         value:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)')
         value:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)')
Line 63: Line 99:
     }
     }


     local leap = year % 4 == 0 and
     local leap =
        year % 4 == 0 and
         (year % 100 ~= 0 or year % 400 == 0)
         (year % 100 ~= 0 or year % 400 == 0)


Line 75: Line 112:


     return string.format(
     return string.format(
         '%04d-%02d-%02d', year, month, day
         '%04d-%02d-%02d',
        year,
        month,
        day
     )
     )
end
end
local MONTHS = {
    'January', 'February', 'March',
    'April', 'May', 'June',
    'July', 'August', 'September',
    'October', 'November', 'December'
}


local function displayDate(iso)
local function displayDate(iso)
Line 93: Line 126:
     local year, month, day =
     local year, month, day =
         iso:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
         iso:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
    if not year then
        return '—'
    end


     return string.format(
     return string.format(
Line 101: Line 138:
     )
     )
end
end
-- ============================================================
-- Public API
-- ============================================================


local function fetchPrograms()
local function fetchPrograms()
     if not mw.ext or not mw.ext.externalData then
     if not mw.ext or not mw.ext.externalData then
         return nil, 'External Data is unavailable.'
         return nil, 'External Data extension is unavailable.'
     end
     end


     local ok, data, errors = pcall(
    local externalData = mw.ext.externalData
         mw.ext.externalData.getExternalData,
 
    if type(externalData.getExternalData) ~= 'function' then
        return nil, 'External Data Lua interface is unavailable.'
    end
 
     local ok, data = pcall(
         externalData.getExternalData,
         {
         {
             url = API_URL,
             url = API_URL,
Line 123: Line 170:


     if not ok then
     if not ok then
         return nil, 'Unable to retrieve the event feed.'
         return nil, 'Unable to retrieve the public event feed.'
     end
     end


     if not data or #data == 0 then
     if type(data) ~= 'table' or next(data) == nil then
         return nil, 'The event feed returned no programs.'
         return nil, 'The public event feed returned no data.'
     end
     end


     return data
     return data
end
-- ============================================================
-- Program normalization
-- ============================================================
local function cleanDescription(value)
    local description = str(value)
    if description == '' then
        return ''
    end
    -- Remove HTML markup from descriptions.
    description = description:gsub('<[^>]*>', '')
    -- Decode common HTML entities.
    description = mw.text.decode(description)
    -- Collapse whitespace.
    description = description:gsub('%s+', ' ')
    description = mw.text.trim(description)
    -- Keep table cells reasonably compact.
    if mw.ustring.len(description) > 240 then
        description =
            mw.ustring.sub(description, 1, 237) .. '…'
    end
    return description
end
end


Line 136: Line 213:
     local programs = {}
     local programs = {}


    -- External Data may return an array of rows.
     for _, row in ipairs(data) do
     for _, row in ipairs(data) do
         local name = firstNonEmpty(row.name)
         if type(row) == 'table' then
            local name = firstNonEmpty(
                row.name,
                row.title
            )


        if name ~= '' then
            if name ~= '' then
            local description =
                local startDate = normalizeDate(
                firstNonEmpty(row.description)
                    firstNonEmpty(
                        row.startDate,
                        row.start_date,
                        row.start
                    )
                )


            -- Keep descriptions compact and plain-text.
                local endDate = normalizeDate(
            description = mw.text.decode(description)
                    firstNonEmpty(
            description = description:gsub('<[^>]*>', '')
                        row.endDate,
            description = description:gsub('%s+', ' ')
                        row.end_date,
                        row['end']
                    )
                )


            if mw.ustring.len(description) > 240 then
                 local description = cleanDescription(
                 description =
                     firstNonEmpty(
                     mw.ustring.sub(description, 1, 237)
                        row.description,
                     .. '...'
                        row.summary
            end
                     )
                )


            local startDate = normalizeDate(
                local url = validUrl(
                firstNonEmpty(row.startDate, row.start)
                    firstNonEmpty(
            )
                        row.url,
                        row.website,
                        row.link
                    )
                )


            local endDate = normalizeDate(
                 programs[#programs + 1] = {
                 firstNonEmpty(row.endDate, row['end'])
                    name = name,
            )
                    description = description,
 
                    startDate = startDate,
            programs[#programs + 1] = {
                    endDate = endDate,
                name = name,
                    url = url
                description = description,
                }
                url = validUrl(row.url),
             end
                startDate = startDate,
                endDate = endDate
             }
         end
         end
     end
     end


     table.sort(programs, function(a, b)
     table.sort(programs, function(a, b)
         return mw.ustring.lower(a.name) <
         local aName = mw.ustring.lower(a.name)
            mw.ustring.lower(b.name)
        local bName = mw.ustring.lower(b.name)
 
        return aName < bName
     end)
     end)


Line 180: Line 274:
end
end


local function addDateCell(tr, date, className)
-- ============================================================
     local cell = tr:tag('td')
-- HTML helpers
-- ============================================================
 
local function addDateCell(row, date, className)
     local cell = row:tag('td')
         :addClass(className)
         :addClass(className)
         :attr('data-sort-value', date or '9999-12-31')
         :attr(
            'data-sort-value',
            date or '9999-12-31'
        )


     if date then
     if date then
         cell:tag('time')
         addText(
            :attr('datetime', date)
            cell:tag('time')
             :text(displayDate(date))
                :attr('datetime', date),
            displayDate(date)
        )
    else
        addText(cell, '—')
    end
 
    return cell
end
 
local function addHeader(row, heading)
    return addText(
        row:tag('th')
            :attr('scope', 'col'),
        heading
    )
end
 
local function addProgramRow(tbody, program)
    local row = tbody:tag('tr')
        :addClass('ysws-program')
        :attr('data-start', program.startDate or '')
        :attr('data-end', program.endDate or '')
 
    -- Program name
    addText(
        row:tag('th')
            :attr('scope', 'row')
            :addClass('ysws-name'),
        program.name
    )
 
    -- Description
    addText(
        row:tag('td')
             :addClass('ysws-description'),
        program.description ~= ''
            and program.description
            or '—'
    )
 
    -- Start date
    addDateCell(
        row,
        program.startDate,
        'ysws-start'
    )
 
    -- End date
    addDateCell(
        row,
        program.endDate,
        'ysws-end'
    )
 
    -- Status is calculated by client-side JavaScript.
    addText(
        row:tag('td')
            :addClass('ysws-status'),
        'Unknown'
    )
 
    -- Website
    local websiteCell = row:tag('td')
        :addClass('ysws-website')
 
    if program.url then
        addText(
            websiteCell:tag('a')
                :attr('href', program.url)
                :attr('rel', 'noopener noreferrer'),
            'Website'
        )
     else
     else
         cell:text('—')
         addText(websiteCell, '—')
     end
     end
    return row
end
end
-- ============================================================
-- Directory renderer
-- ============================================================


local function renderTable(programs)
local function renderTable(programs)
Line 198: Line 377:
         :addClass('ysws-directory')
         :addClass('ysws-directory')


     root:tag('p')
     -- Entry count
        :addClass('ysws-directory-count')
    addText(
         :text(string.format(
        root:tag('p')
            :addClass('ysws-directory-count'),
         string.format(
             '%d public event entries listed.',
             '%d public event entries listed.',
             #programs
             #programs
         ))
         )
    )


    -- Search controls
     local controls = root:tag('div')
     local controls = root:tag('div')
         :addClass('ysws-controls')
         :addClass('ysws-controls')


     controls:tag('label')
     addText(
        :attr('for', 'ysws-search')
        controls:tag('label')
         :text('Search programs')
            :attr('for', 'ysws-search'),
         'Search programs'
    )


     controls:tag('input')
     controls:tag('input')
         :attr('id', 'ysws-search')
         :attr('id', 'ysws-search')
         :attr('type', 'search')
         :attr('type', 'search')
         :attr('placeholder', 'Search name or description')
         :attr(
            'placeholder',
            'Search name or description'
        )
         :attr('autocomplete', 'off')
         :attr('autocomplete', 'off')
         :attr('aria-controls', 'ysws-table')
         :attr('aria-controls', 'ysws-table')
Line 223: Line 411:
         :attr('role', 'status')
         :attr('role', 'status')
         :attr('aria-live', 'polite')
         :attr('aria-live', 'polite')
        :text('')


    -- Table wrapper
     local wrapper = root:tag('div')
     local wrapper = root:tag('div')
         :addClass('ysws-table-wrapper')
         :addClass('ysws-table-wrapper')
Line 234: Line 422:
         :addClass('ysws-table')
         :addClass('ysws-table')


    -- Table headings
     local header = tbl:tag('thead'):tag('tr')
     local header = tbl:tag('thead'):tag('tr')


     for _, heading in ipairs({
     addHeader(header, 'Program')
        'Program',
    addHeader(header, 'Description')
        'Description',
    addHeader(header, 'Start date')
        'Start date',
    addHeader(header, 'End date')
        'End date',
     addHeader(header, 'Status')
        'Status',
    addHeader(header, 'Website')
        'Website'
     }) do
        header:tag('th')
            :attr('scope', 'col')
            :text(heading)
    end


    -- Table body
     local tbody = tbl:tag('tbody')
     local tbody = tbl:tag('tbody')


     for _, program in ipairs(programs) do
     for _, program in ipairs(programs) do
         local tr = tbody:tag('tr')
         addProgramRow(tbody, program)
            :addClass('ysws-program')
            :attr('data-start', program.startDate or '')
            :attr('data-end', program.endDate or '')
 
        tr:tag('th')
            :attr('scope', 'row')
            :addClass('ysws-name')
            :text(program.name)
 
        tr:tag('td')
            :addClass('ysws-description')
            :text(program.description ~= ''
                and program.description or '—')
 
        addDateCell(tr, program.startDate, 'ysws-start')
        addDateCell(tr, program.endDate, 'ysws-end')
 
        tr:tag('td')
            :addClass('ysws-status')
            :text('Unknown')
 
        local website = tr:tag('td')
            :addClass('ysws-website')
 
        if program.url then
            website:tag('a')
                :attr('href', program.url)
                :attr('rel', 'noopener noreferrer')
                :text('Website')
        else
            website:text('—')
        end
     end
     end


     root:tag('p')
     -- Empty search result message
    local empty = root:tag('p')
         :addClass('ysws-empty')
         :addClass('ysws-empty')
         :attr('hidden', 'hidden')
         :attr('hidden', 'hidden')
        :text('No programs match your search.')


     root:tag('p')
     addText(
        :addClass('ysws-source-note')
        empty,
         :text(
        'No programs match your search.'
            'Generated from the public Hack Club events API. '
    )
 
    -- Source information
    addText(
        root:tag('p')
            :addClass('ysws-source-note'),
         'Generated from the public Hack Club events API. '
             .. 'Dates and descriptions reflect the public feed.'
             .. 'Dates and descriptions reflect the public feed.'
        )
    )


     return tostring(root)
     return tostring(root)
end
end
-- ============================================================
-- Public entry point
-- ============================================================


function p.main(frame)
function p.main(frame)
Line 309: Line 471:
             mw.html.create('p')
             mw.html.create('p')
                 :addClass('error')
                 :addClass('error')
                 :text(err)
                 :wikitext(
                    mw.text.nowiki(
                        err or 'Unable to load programs.'
                    )
                )
         )
         )
     end
     end
Line 316: Line 482:


     if #programs == 0 then
     if #programs == 0 then
         return 'No public programs were returned.'
         return tostring(
            mw.html.create('p')
                :addClass('error')
                :wikitext(
                    'No public programs were returned.'
                )
        )
     end
     end