Module:DynamicYSWSList/sandbox
Jump to navigation
Jump to search
Documentation for this module may be created at Module:DynamicYSWSList/sandbox/doc
-- Module:DynamicYSWSList
-- 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 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)
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
-- 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
local function validUrl(url)
url = str(url)
if url:match('^https?://[^%s<>"\']+$') then
return url
end
return nil
end
-- ============================================================
-- Date handling
-- ============================================================
local function normalizeDate(value)
value = str(value)
-- Supports 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 function displayDate(iso)
if not iso then
return '—'
end
local year, month, day =
iso:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
if not year then
return '—'
end
return string.format(
'%d %s %s',
tonumber(day),
MONTHS[tonumber(month)],
year
)
end
-- ============================================================
-- Public API
-- ============================================================
local function fetchPrograms()
if not mw.ext or not mw.ext.externalData then
return nil, 'External Data extension is unavailable.'
end
local externalData = mw.ext.externalData
if type(externalData.getExternalData) ~= 'function' then
return nil, 'External Data Lua interface is unavailable.'
end
local ok, data = pcall(
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 public event feed.'
end
if type(data) ~= 'table' or next(data) == nil then
return nil, 'The public event feed returned no data.'
end
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
local function normalizePrograms(data)
local programs = {}
-- External Data may return an array of rows.
for _, row in ipairs(data) do
if type(row) == 'table' then
local name = firstNonEmpty(
row.name,
row.title
)
if name ~= '' then
local startDate = normalizeDate(
firstNonEmpty(
row.startDate,
row.start_date,
row.start
)
)
local endDate = normalizeDate(
firstNonEmpty(
row.endDate,
row.end_date,
row['end']
)
)
local description = cleanDescription(
firstNonEmpty(
row.description,
row.summary
)
)
local url = validUrl(
firstNonEmpty(
row.url,
row.website,
row.link
)
)
programs[#programs + 1] = {
name = name,
description = description,
startDate = startDate,
endDate = endDate,
url = url
}
end
end
end
table.sort(programs, function(a, b)
local aName = mw.ustring.lower(a.name)
local bName = mw.ustring.lower(b.name)
return aName < bName
end)
return programs
end
-- ============================================================
-- HTML helpers
-- ============================================================
local function addDateCell(row, date, className)
local cell = row:tag('td')
:addClass(className)
:attr(
'data-sort-value',
date or '9999-12-31'
)
if date then
addText(
cell:tag('time')
: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
addText(websiteCell, '—')
end
return row
end
-- ============================================================
-- Directory renderer
-- ============================================================
local function renderTable(programs)
local root = mw.html.create('div')
:addClass('ysws-directory')
-- Entry count
addText(
root:tag('p')
:addClass('ysws-directory-count'),
string.format(
'%d public event entries listed.',
#programs
)
)
-- Search controls
local controls = root:tag('div')
:addClass('ysws-controls')
addText(
controls:tag('label')
:attr('for', 'ysws-search'),
'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')
-- Table wrapper
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')
-- Table headings
local header = tbl:tag('thead'):tag('tr')
addHeader(header, 'Program')
addHeader(header, 'Description')
addHeader(header, 'Start date')
addHeader(header, 'End date')
addHeader(header, 'Status')
addHeader(header, 'Website')
-- Table body
local tbody = tbl:tag('tbody')
for _, program in ipairs(programs) do
addProgramRow(tbody, program)
end
-- Empty search result message
local empty = root:tag('p')
:addClass('ysws-empty')
:attr('hidden', 'hidden')
addText(
empty,
'No programs match your search.'
)
-- 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.'
)
return tostring(root)
end
-- ============================================================
-- Public entry point
-- ============================================================
function p.main(frame)
local data, err = fetchPrograms()
if not data then
return tostring(
mw.html.create('p')
:addClass('error')
:wikitext(
mw.text.nowiki(
err or 'Unable to load programs.'
)
)
)
end
local programs = normalizePrograms(data)
if #programs == 0 then
return tostring(
mw.html.create('p')
:addClass('error')
:wikitext(
'No public programs were returned.'
)
)
end
return renderTable(programs)
end
return p