Module:DynamicYSWSList/sandbox

Revision as of 12:59, 5 November 2026 by U0AHNB20WDD (talk | contribs) (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 ~...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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


-- 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 ~= '' then
            return value
        end
    end
    return ''
end

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

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

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

    if not year then
        return nil
    end

    year = tonumber(year)
    month = tonumber(month)
    day = tonumber(day)

    if not year or not month or not day then
        return nil
    end

    if month < 1 or month > 12 then
        return nil
    end

    local days = {
        31, 28, 31, 30, 31, 30,
        31, 31, 30, 31, 30, 31
    }

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

    if leap then
        days[2] = 29
    end

    if day < 1 or day > days[month] then
        return nil
    end

    return string.format(
        '%04d-%02d-%02d', year, month, day
    )
end

local MONTHS = {
    'January', 'February', 'March',
    'April', 'May', 'June',
    'July', 'August', 'September',
    'October', 'November', 'December'
}

local function displayDate(iso)
    if not iso then
        return '—'
    end

    local year, month, day =
        iso:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')

    return string.format(
        '%d %s %s',
        tonumber(day),
        MONTHS[tonumber(month)],
        year
    )
end

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

    local ok, data, errors = pcall(
        mw.ext.externalData.getExternalData,
        {
            url = API_URL,
            format = 'json with jsonpath',
            data = {
                name = '$.data[*].name',
                description = '$.data[*].description',
                url = '$.data[*].url',
                startDate = '$.data[*].startDate',
                endDate = '$.data[*].endDate'
            }
        }
    )

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

    if not data or #data == 0 then
        return nil, 'The event feed returned no programs.'
    end

    return data
end

local function normalizePrograms(data)
    local programs = {}

    for _, row in ipairs(data) do
        local name = firstNonEmpty(row.name)

        if name ~= '' then
            local description =
                firstNonEmpty(row.description)

            -- Keep descriptions compact and plain-text.
            description = mw.text.decode(description)
            description = description:gsub('<[^>]*>', '')
            description = description:gsub('%s+', ' ')

            if mw.ustring.len(description) > 240 then
                description =
                    mw.ustring.sub(description, 1, 237)
                    .. '...'
            end

            local startDate = normalizeDate(
                firstNonEmpty(row.startDate, row.start)
            )

            local endDate = normalizeDate(
                firstNonEmpty(row.endDate, row['end'])
            )

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

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

    return programs
end

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

    if date then
        cell:tag('time')
            :attr('datetime', date)
            :text(displayDate(date))
    else
        cell:text('—')
    end
end

local function renderTable(programs)
    local root = mw.html.create('div')
        :addClass('ysws-directory')

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

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

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

    controls:tag('input')
        :attr('id', 'ysws-search')
        :attr('type', 'search')
        :attr('placeholder', 'Search name or description')
        :attr('autocomplete', 'off')
        :attr('aria-controls', 'ysws-table')

    controls:tag('p')
        :addClass('ysws-results')
        :attr('role', 'status')
        :attr('aria-live', 'polite')
        :text('')

    local wrapper = root:tag('div')
        :addClass('ysws-table-wrapper')

    local tbl = wrapper:tag('table')
        :attr('id', 'ysws-table')
        :addClass('wikitable')
        :addClass('sortable')
        :addClass('ysws-table')

    local header = tbl:tag('thead'):tag('tr')

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

    local tbody = tbl:tag('tbody')

    for _, program in ipairs(programs) do
        local tr = tbody:tag('tr')
            :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

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

    root:tag('p')
        :addClass('ysws-source-note')
        :text(
            'Generated from the public Hack Club events API. '
            .. 'Dates and descriptions reflect the public feed.'
        )

    return tostring(root)
end

function p.main(frame)
    local data, err = fetchPrograms()

    if not data then
        return tostring(
            mw.html.create('p')
                :addClass('error')
                :text(err)
        )
    end

    local programs = normalizePrograms(data)

    if #programs == 0 then
        return 'No public programs were returned.'
    end

    return renderTable(programs)
end

return p