Module:Timeline: Difference between revisions

From aquila
Jump to navigation Jump to search
No edit summary
mNo edit summary
Line 1: Line 1:
local p = {}
local p = {}
 
-- Base date for Year 1, Day 1: 2026-07-28
local LAUNCH = { year = 2026, month = 7, day = 28 }
local BASE_TIMESTAMP = 1785206400 -- 2026-07-28 00:00:00 UTC
local SECONDS_PER_DAY = 86400
local SEPARATOR = ";"
 
local dayNames = {
local SPLIT_CHARACTERS = true
     "Early January", "January--February", "February--March", "March--April",
     "April--May", "May--June", "June", "June--July",
local LEGACY_MAX_EVENTS = 9
     "July--August", "August--September", "September--October",
     "October--November", "November--December", "Late December"
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 function calculateDate(year, dayIndex)
     year = tonumber(year) or 1
local SECTIONS = {
     dayIndex = tonumber(dayIndex) or 1
    Global    = "Global News",
     local daysOffset = (year - 1) * 14 + (dayIndex - 1)
     Domestic  = "Domestic News",
    local targetTimestamp = BASE_TIMESTAMP + (daysOffset * SECONDS_PER_DAY)
     Character = "Character News",
     return os.date("!%Y-%m-%d", targetTimestamp)
}
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 formatLink(text)
local function errorText(message)
     if not text or text == "" then return "" end
     return '<strong class="error">Timeline error: ' .. message .. "</strong>"
     text = mw.text.trim(text)
end
     -- If already bracketed, quoted, or styled, don't wrap again
     if text:sub(1, 2) == "[[" or text:sub(1, 1) == "'" or text:sub(1, 1) == "<" then
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 p.renderTable(frame)
local function linkifyCharacters(text)
     local args = frame:getParent().args
     text = trim(text)
     local year = tonumber(args.Year) or 1
     if text == "" then return "" end
     local category = args.Category or "NEWS"
     if not SPLIT_CHARACTERS or text:find("[[", 1, true) or text:find("^[<']") then
    local html = ""
        return linkify(text)
 
     end
     -- Render Section Header Row
     local parts = {}
     html = html .. "|-\n"
    for part in (text .. ","):gmatch("(.-),") do
    html = html .. '! colspan="4" style="background-color: #f1f5f9; color: #0b132b; text-align: center; font-weight: bold; padding: 6px;" | ' .. string.upper(category) .. ' NEWS\n'
        part = trim(part)
 
        if part ~= "" then parts[#parts + 1] = linkify(part) end
     -- Collect events per day (up to 9 events per day)
    end
     local schedule = {}
    return table.concat(parts, ", ")
     for i = 1, 14 do
end
        schedule[i] = {}
        for pIdx = 1, 9 do
local function splitFields(line)
            local post = args['Day' .. i .. '_' .. pIdx .. '_Post']
    line = line:gsub("(&#?%w+);", "%1\1")
            local char = args['Day' .. i .. '_' .. pIdx .. '_Char']
    local fields = {}
             local dateOverride = args['Day' .. i .. '_' .. pIdx .. '_Date']
    for field in (line .. SEPARATOR):gmatch("(.-)" .. SEP_PATTERN) do
           
        fields[#fields + 1] = trim((field:gsub("\1", ";")))
            if post and post ~= "" then
    end
                 local formattedPost = formatLink(post)
    return fields
                 local formattedChar = formatLink(char)
end
                 local finalDate = (dateOverride and dateOverride ~= "") and dateOverride or calculateDate(year, i)
               
local function parseLine(line)
                table.insert(schedule[i], {
     line = trim(line)
                    post = formattedPost,
    if line == "" then return nil end
                    char = formattedChar,
                    date = finalDate
     local f = splitFields(line)
                })
     local post, char, date = f[1] or "", f[2] or "", f[3] or ""
            end
    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
 
     -- Render Table Rows
     return events
     local i = 1
end
     while i <= 14 do
         if #schedule[i] > 0 then
local function cell(content, attrs)
             local count = #schedule[i]
    return "<td" .. (attrs or "") .. ">" .. content .. "</td>"
             local inUniverseLabel = dayNames[i] .. ", Year " .. year
end
              
             for idx, event in ipairs(schedule[i]) do
local function rangeLabel(first, last, year)
                 html = html .. "|-\n"
    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
                     html = html .. '| rowspan="' .. count .. '" | ' .. inUniverseLabel .. '\n'
                     row[#row + 1] = cell(
                        DAY_LABELS[day] .. ", Year " .. year,
                        count > 1 and (' rowspan="' .. count .. '"') or ""
                    )
                 end
                 end
                 html = html .. '| ' .. event.post .. '\n'
                 row[#row + 1] = cell(ev.post)
                 html = html .. '| ' .. event.char .. '\n'
                 row[#row + 1] = cell(ev.char)
                 if idx == 1 then
                 if span[idx] then
                     html = html .. '| rowspan="' .. count .. '" | ' .. event.date .. '\n'
                     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
             i = i + 1
             day = day + 1
         else
         else
            -- Scan ahead for consecutive empty days to collapse ranges
             local first = day
             local startEmpty = i
             while day <= DAYS_PER_YEAR and #schedule[day] == 0 do
             while i <= 14 and #schedule[i] == 0 do
                 day = day + 1
                 i = i + 1
            end
            local endEmpty = i - 1
           
            local rangeLabel = ""
            if startEmpty == endEmpty then
                rangeLabel = dayNames[startEmpty] .. ", Year " .. year
            else
                rangeLabel = dayNames[startEmpty] .. " to " .. dayNames[endEmpty] .. ", Year " .. year
             end
             end
              
             local last = day - 1
             local startDate = calculateDate(year, startEmpty)
            local endDate = calculateDate(year, endEmpty)
             local startDate, endDate = realDate(year, first), realDate(year, last)
             local dateDisplay = (startEmpty == endEmpty) and startDate or (startDate .. " to " .. endDate)
             local dateText = (first == last) and startDate or (startDate .. " to " .. endDate)
           
             html = html .. "|-\n"
             out[#out + 1] = "<tr>"
            html = html .. "| " .. rangeLabel .. "\n"
                .. cell(rangeLabel(first, last, year))
            html = html .. "| \n"
                .. cell(EMPTY_ROW_TEXT, ' colspan="2"')
            html = html .. "| \n"
                .. cell(dateText)
            html = html .. "| " .. dateDisplay .. "\n"
                .. "</tr>"
         end
         end
     end
     end
 
     return html
    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