Module:Timeline: Difference between revisions
Jump to navigation
Jump to search
No edit summary |
mNo edit summary |
||
| Line 1: | Line 1: | ||
local p = {} | local p = {} | ||
local LAUNCH = { year = 2026, month = 7, day = 28 } | |||
local | |||
local | local SEPARATOR = ";" | ||
local | local SPLIT_CHARACTERS = true | ||
"Early January", " | |||
" | local LEGACY_MAX_EVENTS = 9 | ||
" | |||
" | local EMPTY_ROW_TEXT = "" | ||
local DAY_LABELS = { | |||
"Early January", | |||
"January–February", | |||
"February–March", | |||
"March–April", | |||
"April–May", | |||
"May–June", | |||
"June", | |||
"June–July", | |||
"July–August", | |||
"August–September", | |||
"September–October", | |||
"October–November", | |||
"November–December", | |||
"Late December", | |||
} | } | ||
local DAYS_PER_YEAR = #DAY_LABELS | |||
local | |||
local SECTIONS = { | |||
Global = "Global News", | |||
local | Domestic = "Domestic News", | ||
Character = "Character News", | |||
return | } | ||
local COLUMNS = { | |||
{ "In-universe date", "24%" }, | |||
{ "Post", "34%" }, | |||
{ "Character / nation", "24%" }, | |||
{ "Real-world date", "18%" }, | |||
} | |||
local SEP_PATTERN = (SEPARATOR:gsub("%p", "%%%0")) | |||
local function trim(s) | |||
return (tostring(s or ""):match("^%s*(.-)%s*$")) | |||
end | end | ||
local function | local function errorText(message) | ||
return '<strong class="error">Timeline error: ' .. message .. "</strong>" | |||
end | |||
-- | |||
local function daysFromCivil(y, m, d) | |||
if m <= 2 then y = y - 1 end | |||
local era = math.floor(y / 400) | |||
local yoe = y - era * 400 | |||
local mp = (m + 9) % 12 | |||
local doy = math.floor((153 * mp + 2) / 5) + d - 1 | |||
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy | |||
return era * 146097 + doe - 719468 | |||
end | |||
local function civilFromDays(z) | |||
z = z + 719468 | |||
local era = math.floor(z / 146097) | |||
local doe = z - era * 146097 | |||
local yoe = math.floor( | |||
(doe - math.floor(doe / 1460) + math.floor(doe / 36524) - math.floor(doe / 146096)) / 365 | |||
) | |||
local y = yoe + era * 400 | |||
local doy = doe - (365 * yoe + math.floor(yoe / 4) - math.floor(yoe / 100)) | |||
local mp = math.floor((5 * doy + 2) / 153) | |||
local d = doy - math.floor((153 * mp + 2) / 5) + 1 | |||
local m = mp + 3 | |||
if m > 12 then m = m - 12 end | |||
if m <= 2 then y = y + 1 end | |||
return y, m, d | |||
end | |||
local LAUNCH_DAYNUM = daysFromCivil(LAUNCH.year, LAUNCH.month, LAUNCH.day) | |||
local function realDate(year, day) | |||
local z = LAUNCH_DAYNUM + (year - 1) * DAYS_PER_YEAR + (day - 1) | |||
local y, m, d = civilFromDays(z) | |||
return string.format("%04d-%02d-%02d", y, m, d) | |||
end | |||
local function linkify(text) | |||
text = trim(text) | |||
if text == "" then return "" end | |||
if text:find("[[", 1, true) or text:find("^[%[<']") then | |||
return text | return text | ||
end | end | ||
return "[[" .. text .. "]]" | return "[[" .. text .. "]]" | ||
end | end | ||
function | local function linkifyCharacters(text) | ||
text = trim(text) | |||
if text == "" then return "" end | |||
if not SPLIT_CHARACTERS or text:find("[[", 1, true) or text:find("^[<']") then | |||
return linkify(text) | |||
end | |||
local parts = {} | |||
for part in (text .. ","):gmatch("(.-),") do | |||
part = trim(part) | |||
if part ~= "" then parts[#parts + 1] = linkify(part) end | |||
end | |||
local | return table.concat(parts, ", ") | ||
end | |||
local function splitFields(line) | |||
line = line:gsub("(&#?%w+);", "%1\1") | |||
local fields = {} | |||
local | for field in (line .. SEPARATOR):gmatch("(.-)" .. SEP_PATTERN) do | ||
fields[#fields + 1] = trim((field:gsub("\1", ";"))) | |||
end | |||
return fields | |||
end | |||
local function parseLine(line) | |||
line = trim(line) | |||
if line == "" then return nil end | |||
local f = splitFields(line) | |||
local post, char, date = f[1] or "", f[2] or "", f[3] or "" | |||
if post == "" and char == "" and date == "" then return nil end | |||
if post == "" then | |||
post = '<span class="error">(missing post title)</span>' | |||
else | |||
post = linkify(post) | |||
end | |||
if #f > 3 then | |||
post = post .. ' <span class="error">(too many “' .. SEPARATOR .. '” on this line)</span>' | |||
end | |||
return { post = post, char = linkifyCharacters(char), date = date } | |||
end | |||
local function collectEvents(getArg, day) | |||
local events = {} | |||
local text = getArg("Day" .. day) | |||
if text and trim(text) ~= "" then | |||
for line in (text .. "\n"):gmatch("(.-)\r?\n") do | |||
local ev = parseLine(line) | |||
if ev then events[#events + 1] = ev end | |||
end | |||
end | |||
for n = 1, LEGACY_MAX_EVENTS do | |||
local prefix = "Day" .. day .. "_" .. n .. "_" | |||
local post = trim(getArg(prefix .. "Post")) | |||
if post ~= "" then | |||
events[#events + 1] = { | |||
post = linkify(post), | |||
char = linkifyCharacters(getArg(prefix .. "Char")), | |||
date = trim(getArg(prefix .. "Date")), | |||
} | |||
end | end | ||
end | end | ||
-- | return events | ||
local | end | ||
while | |||
local function cell(content, attrs) | |||
local | return "<td" .. (attrs or "") .. ">" .. content .. "</td>" | ||
local | end | ||
for idx, | local function rangeLabel(first, last, year) | ||
if first == last then | |||
return DAY_LABELS[first] .. ", Year " .. year | |||
end | |||
return DAY_LABELS[first] .. " to " .. DAY_LABELS[last] .. ", Year " .. year | |||
end | |||
local function renderSection(title, year, schedule) | |||
local out = {} | |||
out[#out + 1] = '<div style="overflow-x:auto;">' | |||
out[#out + 1] = '<table class="wikitable aquila-timeline" style="width:100%;">' | |||
out[#out + 1] = '<tr><th colspan="4" style="background-color:#f1f5f9; color:#0b132b; ' | |||
.. 'text-align:center; font-weight:bold; padding:6px; text-transform:uppercase;">' | |||
.. title .. "</th></tr>" | |||
local head = {} | |||
for _, col in ipairs(COLUMNS) do | |||
head[#head + 1] = '<th style="width:' .. col[2] .. ';">' .. col[1] .. "</th>" | |||
end | |||
out[#out + 1] = "<tr>" .. table.concat(head) .. "</tr>" | |||
local day = 1 | |||
while day <= DAYS_PER_YEAR do | |||
local events = schedule[day] | |||
local count = #events | |||
if count > 0 then | |||
local default = realDate(year, day) | |||
local dates, span = {}, {} | |||
for i, ev in ipairs(events) do | |||
dates[i] = (ev.date ~= "") and ev.date or default | |||
end | |||
local i = 1 | |||
while i <= count do | |||
local j = i | |||
while j < count and dates[j + 1] == dates[i] do j = j + 1 end | |||
span[i] = j - i + 1 | |||
i = j + 1 | |||
end | |||
for idx, ev in ipairs(events) do | |||
local row = {} | |||
if idx == 1 then | if idx == 1 then | ||
row[#row + 1] = cell( | |||
DAY_LABELS[day] .. ", Year " .. year, | |||
count > 1 and (' rowspan="' .. count .. '"') or "" | |||
) | |||
end | end | ||
row[#row + 1] = cell(ev.post) | |||
row[#row + 1] = cell(ev.char) | |||
if idx | if span[idx] then | ||
row[#row + 1] = cell( | |||
dates[idx], | |||
span[idx] > 1 and (' rowspan="' .. span[idx] .. '"') or "" | |||
) | |||
end | end | ||
out[#out + 1] = "<tr>" .. table.concat(row) .. "</tr>" | |||
end | end | ||
day = day + 1 | |||
else | else | ||
local first = day | |||
local | while day <= DAYS_PER_YEAR and #schedule[day] == 0 do | ||
while | day = day + 1 | ||
end | end | ||
local last = day - 1 | |||
local startDate = | |||
local startDate, endDate = realDate(year, first), realDate(year, last) | |||
local | local dateText = (first == last) and startDate or (startDate .. " to " .. endDate) | ||
out[#out + 1] = "<tr>" | |||
.. cell(rangeLabel(first, last, year)) | |||
.. cell(EMPTY_ROW_TEXT, ' colspan="2"') | |||
.. cell(dateText) | |||
.. "</tr>" | |||
end | end | ||
end | end | ||
return | out[#out + 1] = "</table>" | ||
out[#out + 1] = "</div>" | |||
return table.concat(out, "\n") | |||
end | |||
function p.render(frame) | |||
local own = frame.args | |||
local parent = frame:getParent() | |||
local inherited = parent and parent.args or {} | |||
local function getArg(name) | |||
local v = inherited[name] | |||
if v == nil or trim(v) == "" then v = own[name] end | |||
return v | |||
end | |||
local key = trim(getArg("Category")) | |||
local title = SECTIONS[key] | |||
if not title then | |||
return errorText('unknown Category "' .. key .. '" (use Global, Domestic or Character).') | |||
end | |||
local year = tonumber(trim(getArg("Year"))) | |||
if not year or year < 1 or year ~= math.floor(year) then | |||
return errorText("set <code>Year</code> to a whole number, 1 or higher.") | |||
end | |||
local schedule = {} | |||
for day = 1, DAYS_PER_YEAR do | |||
schedule[day] = collectEvents(getArg, day) | |||
end | |||
return renderSection(title, year, schedule) | |||
end | end | ||
return p | return p | ||
Revision as of 21:32, 23 October 2026
Documentation for this module may be created at Module:Timeline/doc
local p = {}
local LAUNCH = { year = 2026, month = 7, day = 28 }
local SEPARATOR = ";"
local SPLIT_CHARACTERS = true
local LEGACY_MAX_EVENTS = 9
local EMPTY_ROW_TEXT = ""
local DAY_LABELS = {
"Early January",
"January–February",
"February–March",
"March–April",
"April–May",
"May–June",
"June",
"June–July",
"July–August",
"August–September",
"September–October",
"October–November",
"November–December",
"Late December",
}
local DAYS_PER_YEAR = #DAY_LABELS
local SECTIONS = {
Global = "Global News",
Domestic = "Domestic News",
Character = "Character News",
}
local COLUMNS = {
{ "In-universe date", "24%" },
{ "Post", "34%" },
{ "Character / nation", "24%" },
{ "Real-world date", "18%" },
}
local SEP_PATTERN = (SEPARATOR:gsub("%p", "%%%0"))
local function trim(s)
return (tostring(s or ""):match("^%s*(.-)%s*$"))
end
local function errorText(message)
return '<strong class="error">Timeline error: ' .. message .. "</strong>"
end
local function daysFromCivil(y, m, d)
if m <= 2 then y = y - 1 end
local era = math.floor(y / 400)
local yoe = y - era * 400
local mp = (m + 9) % 12
local doy = math.floor((153 * mp + 2) / 5) + d - 1
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
return era * 146097 + doe - 719468
end
local function civilFromDays(z)
z = z + 719468
local era = math.floor(z / 146097)
local doe = z - era * 146097
local yoe = math.floor(
(doe - math.floor(doe / 1460) + math.floor(doe / 36524) - math.floor(doe / 146096)) / 365
)
local y = yoe + era * 400
local doy = doe - (365 * yoe + math.floor(yoe / 4) - math.floor(yoe / 100))
local mp = math.floor((5 * doy + 2) / 153)
local d = doy - math.floor((153 * mp + 2) / 5) + 1
local m = mp + 3
if m > 12 then m = m - 12 end
if m <= 2 then y = y + 1 end
return y, m, d
end
local LAUNCH_DAYNUM = daysFromCivil(LAUNCH.year, LAUNCH.month, LAUNCH.day)
local function realDate(year, day)
local z = LAUNCH_DAYNUM + (year - 1) * DAYS_PER_YEAR + (day - 1)
local y, m, d = civilFromDays(z)
return string.format("%04d-%02d-%02d", y, m, d)
end
local function linkify(text)
text = trim(text)
if text == "" then return "" end
if text:find("[[", 1, true) or text:find("^[%[<']") then
return text
end
return "[[" .. text .. "]]"
end
local function linkifyCharacters(text)
text = trim(text)
if text == "" then return "" end
if not SPLIT_CHARACTERS or text:find("[[", 1, true) or text:find("^[<']") then
return linkify(text)
end
local parts = {}
for part in (text .. ","):gmatch("(.-),") do
part = trim(part)
if part ~= "" then parts[#parts + 1] = linkify(part) end
end
return table.concat(parts, ", ")
end
local function splitFields(line)
line = line:gsub("(&#?%w+);", "%1\1")
local fields = {}
for field in (line .. SEPARATOR):gmatch("(.-)" .. SEP_PATTERN) do
fields[#fields + 1] = trim((field:gsub("\1", ";")))
end
return fields
end
local function parseLine(line)
line = trim(line)
if line == "" then return nil end
local f = splitFields(line)
local post, char, date = f[1] or "", f[2] or "", f[3] or ""
if post == "" and char == "" and date == "" then return nil end
if post == "" then
post = '<span class="error">(missing post title)</span>'
else
post = linkify(post)
end
if #f > 3 then
post = post .. ' <span class="error">(too many “' .. SEPARATOR .. '” on this line)</span>'
end
return { post = post, char = linkifyCharacters(char), date = date }
end
local function collectEvents(getArg, day)
local events = {}
local text = getArg("Day" .. day)
if text and trim(text) ~= "" then
for line in (text .. "\n"):gmatch("(.-)\r?\n") do
local ev = parseLine(line)
if ev then events[#events + 1] = ev end
end
end
for n = 1, LEGACY_MAX_EVENTS do
local prefix = "Day" .. day .. "_" .. n .. "_"
local post = trim(getArg(prefix .. "Post"))
if post ~= "" then
events[#events + 1] = {
post = linkify(post),
char = linkifyCharacters(getArg(prefix .. "Char")),
date = trim(getArg(prefix .. "Date")),
}
end
end
return events
end
local function cell(content, attrs)
return "<td" .. (attrs or "") .. ">" .. content .. "</td>"
end
local function rangeLabel(first, last, year)
if first == last then
return DAY_LABELS[first] .. ", Year " .. year
end
return DAY_LABELS[first] .. " to " .. DAY_LABELS[last] .. ", Year " .. year
end
local function renderSection(title, year, schedule)
local out = {}
out[#out + 1] = '<div style="overflow-x:auto;">'
out[#out + 1] = '<table class="wikitable aquila-timeline" style="width:100%;">'
out[#out + 1] = '<tr><th colspan="4" style="background-color:#f1f5f9; color:#0b132b; '
.. 'text-align:center; font-weight:bold; padding:6px; text-transform:uppercase;">'
.. title .. "</th></tr>"
local head = {}
for _, col in ipairs(COLUMNS) do
head[#head + 1] = '<th style="width:' .. col[2] .. ';">' .. col[1] .. "</th>"
end
out[#out + 1] = "<tr>" .. table.concat(head) .. "</tr>"
local day = 1
while day <= DAYS_PER_YEAR do
local events = schedule[day]
local count = #events
if count > 0 then
local default = realDate(year, day)
local dates, span = {}, {}
for i, ev in ipairs(events) do
dates[i] = (ev.date ~= "") and ev.date or default
end
local i = 1
while i <= count do
local j = i
while j < count and dates[j + 1] == dates[i] do j = j + 1 end
span[i] = j - i + 1
i = j + 1
end
for idx, ev in ipairs(events) do
local row = {}
if idx == 1 then
row[#row + 1] = cell(
DAY_LABELS[day] .. ", Year " .. year,
count > 1 and (' rowspan="' .. count .. '"') or ""
)
end
row[#row + 1] = cell(ev.post)
row[#row + 1] = cell(ev.char)
if span[idx] then
row[#row + 1] = cell(
dates[idx],
span[idx] > 1 and (' rowspan="' .. span[idx] .. '"') or ""
)
end
out[#out + 1] = "<tr>" .. table.concat(row) .. "</tr>"
end
day = day + 1
else
local first = day
while day <= DAYS_PER_YEAR and #schedule[day] == 0 do
day = day + 1
end
local last = day - 1
local startDate, endDate = realDate(year, first), realDate(year, last)
local dateText = (first == last) and startDate or (startDate .. " to " .. endDate)
out[#out + 1] = "<tr>"
.. cell(rangeLabel(first, last, year))
.. cell(EMPTY_ROW_TEXT, ' colspan="2"')
.. cell(dateText)
.. "</tr>"
end
end
out[#out + 1] = "</table>"
out[#out + 1] = "</div>"
return table.concat(out, "\n")
end
function p.render(frame)
local own = frame.args
local parent = frame:getParent()
local inherited = parent and parent.args or {}
local function getArg(name)
local v = inherited[name]
if v == nil or trim(v) == "" then v = own[name] end
return v
end
local key = trim(getArg("Category"))
local title = SECTIONS[key]
if not title then
return errorText('unknown Category "' .. key .. '" (use Global, Domestic or Character).')
end
local year = tonumber(trim(getArg("Year")))
if not year or year < 1 or year ~= math.floor(year) then
return errorText("set <code>Year</code> to a whole number, 1 or higher.")
end
local schedule = {}
for day = 1, DAYS_PER_YEAR do
schedule[day] = collectEvents(getArg, day)
end
return renderSection(title, year, schedule)
end
return p