Module:DynamicYSWSList/sandbox: Difference between revisions
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 ~..." |
U0AHNB20WDD (talk | contribs) No edit summary |
||
| Line 1: | Line 1: | ||
-- Module:DynamicYSWSList | -- Module:DynamicYSWSList | ||
-- Public Hack Club events | -- 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) | ||
-- | -- 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 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 | 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, | 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 | if type(data) ~= 'table' or next(data) == nil then | ||
return nil, 'The event feed returned no | 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 | |||
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( | |||
description = | 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 | ||
end | end | ||
table.sort(programs, function(a, b) | table.sort(programs, function(a, b) | ||
local aName = mw.ustring.lower(a.name) | |||
local bName = mw.ustring.lower(b.name) | |||
return aName < bName | |||
end) | end) | ||
| Line 180: | Line 274: | ||
end | end | ||
local function addDateCell( | -- ============================================================ | ||
local cell = | -- 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( | ||
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 | else | ||
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 | ||
addText( | |||
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( | ||
controls:tag('label') | |||
: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') | ||
-- 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') | ||
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') | local tbody = tbl:tag('tbody') | ||
for _, program in ipairs(programs) do | for _, program in ipairs(programs) do | ||
addProgramRow(tbody, program) | |||
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') | ||
root:tag('p') | 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.' | .. '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 | ||