Module:Recurrence
Documentation for this module may be created at Module:Recurrence/doc
-- Module:Recurrence — expand a recurrence rule into occurrence date-ranges.
-- Pure-Lua Julian-Day-Number date math (Scribunto sandboxes os.*), so it's
-- deterministic and safe. Feeds materialized rows into the EventOccurrences
-- Cargo table. Draft v1 for review — see [[Module talk:Recurrence]] / test page.
local p = {}
local WD = { SU=0, MO=1, TU=2, WE=3, TH=4, FR=5, SA=6 } -- 0=Sun .. 6=Sat
local WDNAME = {[0]="Sun",[1]="Mon",[2]="Tue",[3]="Wed",[4]="Thu",[5]="Fri",[6]="Sat"}
local WDFULL = {[0]="Sunday",[1]="Monday",[2]="Tuesday",[3]="Wednesday",[4]="Thursday",[5]="Friday",[6]="Saturday"}
local MONTHS = {"January","February","March","April","May","June","July","August","September","October","November","December"}
local ORDNUM = {["1"]=1,["1st"]=1,["2"]=2,["2nd"]=2,["3"]=3,["3rd"]=3,["4"]=4,["4th"]=4,["5"]=5,["5th"]=5,["-1"]=-1,["last"]=-1}
local ORDLABEL= {[1]="1st",[2]="2nd",[3]="3rd",[4]="4th",[5]="5th",[-1]="last"}
local SAFETY = 2000 -- max loop iterations, guards against an unbounded window
-- weekpos accepts 2 / "2nd" / "Last" / -1 alike
local function parsePos(s) if not s or s=="" then return nil end return ORDNUM[tostring(s):lower()] end
-- English ordinal for a day-of-month: 1 -> 1st, 22 -> 22nd
local function ordinal(nStr)
local num=tonumber(nStr); if not num then return nStr end
local t,h = num%10, num%100
local suf = (t==1 and h~=11) and "st" or (t==2 and h~=12) and "nd" or (t==3 and h~=13) and "rd" or "th"
return num..suf
end
-- a checkbox/flag is "on" only for real yes-values (handles "No"/""/"0"/"false")
local function truthy(v) v=tostring(v or ""):lower(); return v~="" and v~="no" and v~="n" and v~="false" and v~="0" end
-- (y,m,d) -> Julian Day Number, and back
local function toJDN(y,m,d)
local a = math.floor((14-m)/12); local yy = y+4800-a; local mm = m+12*a-3
return d + math.floor((153*mm+2)/5) + 365*yy + math.floor(yy/4)
- math.floor(yy/100) + math.floor(yy/400) - 32045
end
local function fromJDN(j)
local a=j+32044; local b=math.floor((4*a+3)/146097); local c=a-math.floor(146097*b/4)
local d2=math.floor((4*c+3)/1461); local e=c-math.floor(1461*d2/4); local m2=math.floor((5*e+2)/153)
local day=e-math.floor((153*m2+2)/5)+1; local month=m2+3-12*math.floor(m2/10)
local year=100*b+d2-4800+math.floor(m2/10); return year,month,day
end
local function wdayJDN(j) return (j+1) % 7 end -- 0=Sun .. 6=Sat
local MONTHNUM = {january=1,february=2,march=3,april=4,may=5,june=6,july=7,august=8,september=9,october=10,november=11,december=12,
jan=1,feb=2,mar=3,apr=4,jun=6,jul=7,aug=8,sep=9,sept=9,oct=10,nov=11,dec=12}
-- accepts "YYYY-MM-DD" (ISO) and "Month D, YYYY" (the PageForms datepicker format)
local function parseJDN(s)
if not s or s=="" then return nil end
s=tostring(s)
local y,m,d = s:match("(%d%d%d%d)%-(%d%d?)%-(%d%d?)")
if y then return toJDN(tonumber(y),tonumber(m),tonumber(d)) end
local mon,dd,yy = s:match("(%a+)%s+(%d%d?),?%s*(%d%d%d%d)")
if mon then local mn=MONTHNUM[mon:lower()]; if mn then return toJDN(tonumber(yy),mn,tonumber(dd)) end end
return nil
end
local function fmtJDN(j) local y,m,d=fromJDN(j); return string.format("%04d-%02d-%02d",y,m,d) end
local function humanDate(j) local y,m,d=fromJDN(j); return MONTHS[m].." "..d..", "..y end
local function humanRange(o) -- "August 1–3, 2024"
if o.e==o.s then return humanDate(o.s) end
local y1,m1,d1=fromJDN(o.s); local y2,m2,d2=fromJDN(o.e)
if y1==y2 and m1==m2 then return MONTHS[m1].." "..d1.."–"..d2..", "..y1 end
if y1==y2 then return MONTHS[m1].." "..d1.." – "..MONTHS[m2].." "..d2..", "..y1 end
return humanDate(o.s).." – "..humanDate(o.e)
end
local function daysInMonth(y,m) return toJDN(m==12 and y+1 or y, m==12 and 1 or m+1, 1) - toJDN(y,m,1) end
-- JDN of the Nth given-weekday in (y,m). pos: 1..5, or -1 = last. nil if it doesn't exist.
local function nthWeekdayJDN(y,m,wd,pos)
if pos == -1 then
local last = toJDN(y,m,daysInMonth(y,m))
return last - ((wdayJDN(last) - wd + 7) % 7)
end
local first = toJDN(y,m,1)
local j = first + ((wd - wdayJDN(first) + 7) % 7) + (pos-1)*7
if j > toJDN(y,m,daysInMonth(y,m)) then return nil end
return j
end
local function splitList(s)
local out={}; if not s or s=="" then return out end
for it in tostring(s):gmatch("[^,;]+") do it=it:gsub("^%s+",""):gsub("%s+$",""); if it~="" then out[#out+1]=it end end
return out
end
local function buildExcludes(s) local set={} for _,v in ipairs(splitList(s)) do local j=parseJDN(v); if j then set[j]=true end end return set end
local function buildMoves(s)
local map={}
for _,pair in ipairs(splitList(s)) do
local a,b = pair:match("(.-)>(.+)") -- "from>to"
local ja,jb = parseJDN(a and a:gsub("%s","")), parseJDN(b and b:gsub("%s",""))
if ja and jb then map[ja]=jb end
end
return map
end
-- rule table -> sorted list of {s=startJDN, e=endJDN}
function p._occurrences(rule)
local fromJ = parseJDN(rule.from) or -1e9
local toJ = parseJDN(rule.to) or 1e9
local startJ = parseJDN(rule.start)
local untilJ = parseJDN(rule["until"]) or 1e9
local span = math.max(1, tonumber(rule.span) or 1)
local interval = math.max(1, tonumber(rule.interval) or 1)
local off = tonumber(rule.offset) or 0 -- start = anchor + offset days (signed)
local exclude = buildExcludes(rule.exclude)
local moves = buildMoves(rule.move)
local hi = math.min(untilJ, toJ)
local freq = tostring(rule.freq or ""):lower()
local weekdays = {}
for _,w in ipairs(splitList(rule.weekdays)) do local n=WD[w:upper()]; if n then weekdays[#weekdays+1]=n end end
local starts, guard = {}, 0
local function keep(j) return j and startJ and j>=startJ and j>=fromJ and j<=hi and j<=toJ and not exclude[j] end
if freq=="daily" and startJ then
local j = startJ
while j <= hi and guard < SAFETY do
if keep(j) then starts[#starts+1]=j end
j = j + interval; guard = guard+1
end
elseif freq=="weekly" and startJ and #weekdays>0 then
local w = startJ - wdayJDN(startJ) -- Sunday of the start week
while w <= hi and guard < SAFETY do
for _,wd in ipairs(weekdays) do local j=w+wd; if keep(j) then starts[#starts+1]=j end end
w = w + interval*7; guard = guard+1
end
elseif freq=="monthly" and startJ then
local y,m = fromJDN(startJ)
local pos = parsePos(rule.weekpos)
local mday = rule.monthday and tonumber(rule.monthday) or nil
-- alternate mode: same weekend slot, day flips between exactly two weekdays
-- each month. Lead day = startday= if given, else the start date's own weekday.
local altflag = truthy(rule.alternate)
local alt = (altflag and pos and #weekdays==2) or false
local startWd, otherWd
if alt then
local sw = (rule.startday and WD[tostring(rule.startday):upper()]) or wdayJDN(startJ)
if sw==weekdays[2] then startWd,otherWd = weekdays[2],weekdays[1]
else startWd,otherWd = weekdays[1],weekdays[2] end
end
local mi = 0 -- month index from the start month
while toJDN(y,m,1) <= hi and guard < SAFETY do
if alt then
local j=nthWeekdayJDN(y,m,(mi%2==0) and startWd or otherWd,pos); if keep(j) then starts[#starts+1]=j end
elseif pos and #weekdays>0 then
for _,wd in ipairs(weekdays) do local j=nthWeekdayJDN(y,m,wd,pos); if keep(j) then starts[#starts+1]=j end end
elseif mday then
local dim=daysInMonth(y,m)
local dd = mday>0 and mday or (dim+mday+1) -- negative counts from month end (-1 = last day)
if dd>=1 and dd<=dim then local j=toJDN(y,m,dd); if keep(j) then starts[#starts+1]=j end end
end
m = m + interval; while m>12 do m=m-12; y=y+1 end; mi=mi+1; guard=guard+1
end
elseif freq=="yearly" and startJ then
local sy,sm,sd = fromJDN(startJ); local y=sy
while toJDN(y,1,1) <= hi and guard < SAFETY do
local j
if rule.month and rule.weekpos and #weekdays>0 then
j = nthWeekdayJDN(y, tonumber(rule.month), weekdays[1], parsePos(rule.weekpos))
elseif sd <= daysInMonth(y,sm) then
j = toJDN(y,sm,sd)
end
if keep(j) then starts[#starts+1]=j end
y = y + interval; guard = guard+1
end
end
-- moves (from>to), then includes (RDATE), then dedupe + sort
local out = {}
for _,j in ipairs(starts) do out[#out+1] = moves[j] or j end
for _,v in ipairs(splitList(rule.include)) do
local j=parseJDN(v); if j and j>=fromJ and j<=toJ then out[#out+1]=j end
end
local seen, uniq = {}, {}
for _,j in ipairs(out) do if not seen[j] then seen[j]=true; uniq[#uniq+1]=j end end
table.sort(uniq)
local res = {}
for _,j in ipairs(uniq) do local so=j+off; res[#res+1] = { s=so, e=so+(span-1) } end
return res
end
-- {{#invoke:Recurrence|list | freq= | interval= | weekdays= | weekpos= | monthday=
-- | month= | start= | until= | span= | exclude= | move= | include= | from= | to=
-- | alternate= | startday= }}
-- alternate=yes (monthly, weekpos + exactly two weekdays): the weekday flips
-- month to month; the lead day is startday= (SA|SU) if set, else the start date's weekday.
function p.list(frame)
local occ = p._occurrences(frame.args)
if #occ==0 then return "''(no occurrences in window)''" end
local out = {}
for _,o in ipairs(occ) do
local s = fmtJDN(o.s).." ''("..WDNAME[wdayJDN(o.s)]..")''"
if o.e~=o.s then s = s.." → "..fmtJDN(o.e).." ''("..WDNAME[wdayJDN(o.e)]..")''" end
out[#out+1] = "* "..s
end
return "'''"..#occ.." occurrence(s):'''\n"..table.concat(out,"\n")
end
-- weekday code list -> "Saturday", "Tuesday and Thursday", "A, B, and C"
local function joinWeekdays(s)
local names={}
for _,w in ipairs(splitList(s)) do local n=WD[w:upper()]; names[#names+1]= n and WDFULL[n] or w end
local k=#names
if k==0 then return "" end
if k==1 then return names[1] end
if k==2 then return names[1].." and "..names[2] end
return table.concat(names,", ",1,k-1)..", and "..names[k]
end
-- {{#invoke:Recurrence|describe | freq= | interval= | weekdays= | weekpos= | monthday=
-- | month= | span= | until= | alternate= | exclude= | move= | include= }}
-- Plain-English rendering of a rule for the event infobox. Empty for one-time events.
function p.describe(frame)
local a = frame.args
local freq = tostring(a.freq or ""):lower()
local interval = math.max(1, tonumber(a.interval) or 1)
local span = math.max(1, tonumber(a.span) or 1)
local posL = ORDLABEL[parsePos(a.weekpos) or 0]
local core
if freq=="daily" then
core = (interval==1 and "Daily" or ("Every "..interval.." days"))
elseif freq=="weekly" then
core = (interval==1 and "Weekly" or ("Every "..interval.." weeks")).." on "..joinWeekdays(a.weekdays)
elseif freq=="monthly" then
local altflag = truthy(a.alternate)
if altflag and posL and #splitList(a.weekdays)==2 then core = "The "..posL.." weekend of every month, alternating "..joinWeekdays(a.weekdays)
elseif posL and a.weekdays and a.weekdays~="" then core = "The "..posL.." "..joinWeekdays(a.weekdays).." of every month"
elseif a.monthday and a.monthday~="" then
local md=tonumber(a.monthday)
if md and md<0 then core = "The "..(md==-1 and "last day" or (ordinal(-md).."-to-last day")).." of every month"
else core = "The "..ordinal(a.monthday).." of every month" end
else core = "Monthly" end
if interval>1 then core = core.." (every "..interval.." months)" end
elseif freq=="yearly" then
if a.month and a.month~="" and posL and a.weekdays and a.weekdays~="" then
core = "The "..posL.." "..joinWeekdays(a.weekdays).." of "..(MONTHS[tonumber(a.month)] or a.month)..", every year"
else core = "Annually" end
else
return ""
end
local out = core
if span>1 then out = out.." ("..span.." days)" end
local off = tonumber(a.offset) or 0
if off ~= 0 then out = out..", starting "..math.abs(off).." day"..(math.abs(off)==1 and "" or "s")..(off<0 and " before" or " after").." the recurring date" end
if a["until"] and a["until"]~="" then out = out..", until "..a["until"] end
local exc={}
if a.exclude and a.exclude~="" then exc[#exc+1]="skipped dates" end
if a.move and a.move~="" then exc[#exc+1]="moved dates" end
if a.include and a.include~="" then exc[#exc+1]="extra dates" end
if #exc>0 then out = out.." — with "..table.concat(exc,", ") end
return out
end
-- {{#invoke:Recurrence|store | _table= | (rule params) | eventtype= | status= }}
-- Emits one {{#cargo_store}} per computed occurrence to materialize rows into
-- <_table> (OccStart, OccEnd [+ EventType, Status]). With no freq but a start
-- date, stores a single row (one-time event: start..until|start).
function p.store(frame)
local a = frame.args
local tmpl = a.rowtemplate or a._table or a.table -- row template that stores one occurrence
if not tmpl or tmpl=="" then return "" end
local occ = p._occurrences(a)
local ft = tostring(a.freq or ""):lower()
if #occ==0 and (ft=="" or ft=="one-time") and a.start and a.start~="" then
local sj=parseJDN(a.start); local ej=parseJDN(a["until"])
if sj then occ={ {s=sj, e=(ej and ej>=sj) and ej or sj} } end
end
local et = a.eventtype or ""
local st = a.status or ""
local out={}
for _,o in ipairs(occ) do
out[#out+1] = "{{"..tmpl.."|OccStart="..fmtJDN(o.s).."|OccEnd="..fmtJDN(o.e).."|EventType="..et.."|Status="..st.."}}"
end
return frame:preprocess(table.concat(out,""))
end
-- {{#invoke:Recurrence|pick | which=nextstart|nextend|laststart|lastend | <rule>
-- | today=YYYY-MM-DD | horizon=YYYY-MM-DD (next lookahead) }}
-- ISO date of the next occurrence (start >= today) or the most recent past one
-- (start < today). Empty string if none / (for *end) if single-day. Recurring
-- events use this to auto-fill Next held on / Last held on.
function p.pick(frame)
local a = frame.args
local which = tostring(a.which or "nextstart"):lower()
local isEnd = which:find("end") ~= nil
local todayJ = parseJDN(a.today)
if which:find("next") then
a.from = a.today; a.to = a.horizon
local o = p._occurrences(a)[1]
if not o then return "" end
if isEnd then return (o.e~=o.s) and fmtJDN(o.e) or "" end
return fmtJDN(o.s)
else
a.from = nil; a.to = a.today -- began..today, bounded by the rule's start
local occ = p._occurrences(a)
while #occ>0 and todayJ and occ[#occ].s >= todayJ do table.remove(occ) end
local o = occ[#occ]
if not o then return "" end
if isEnd then return (o.e~=o.s) and fmtJDN(o.e) or "" end
return fmtJDN(o.s)
end
end
-- Split on ";" only (NOT commas): column labels routinely contain commas.
local function splitSemi(s)
local out = {}
if not s or s == "" then return out end
for it in tostring(s):gmatch("[^;]+") do
it = it:gsub("^%s+", ""):gsub("%s+$", "")
if it ~= "" then out[#out+1] = it end
end
return out
end
-- "theme=Theme; attendance=Attendance" -> ordered {key=,label=} list.
-- An item with no "=" uses its own text as both key and label.
local function parseColumns(s)
local cols = {}
for _, item in ipairs(splitSemi(s)) do
local k, lab = item:match("^(.-)%s*=%s*(.*)$")
if not k or k == "" then k, lab = item, item end
cols[#cols+1] = { key = tostring(k):lower(), label = lab }
end
return cols
end
-- {{#invoke:Recurrence|pasttable | <rule> | to=<today> }}
-- Emits the COMPLETE wikitable for an event's past occurrences: opening "{|",
-- caption, header row, one row per occurrence, closing "|}". (Before 2026-07-26
-- this returned bare rows and Template:Past events supplied the header; arbitrary
-- columns make the header data-dependent, so it had to move in here.)
--
-- Dates are still COMPUTED from the recurrence rule. Every other cell is authored
-- by hand on the calling page and looked up by year.
--
-- Layout options, read from the CALLING template's args ({{Past events}}):
-- columns = key=Label; key2=Label 2 extra columns, in order, placed between
-- the Dates column and the Notes column
-- numbered = yes prepend a "#" column. 1 is always the EARLIEST
-- occurrence, whichever way the table is ordered.
-- year = no drop the auto-linking Year column
-- dates = <text> header for the dates column ("no" drops the column)
-- notes = <text> header for the notes column ("no" drops the column)
-- order = oldest oldest first (default: newest first)
-- sortable = no plain wikitable instead of a sortable one
-- caption = <text> table caption ("no" for none)
-- class, style override the table's class= / style= outright
--
-- Per-occurrence cells:
-- |2024 = note text (unchanged)
-- |2024-theme = value for the "theme" column
-- |2024-08-09-theme = same, for an event occurring several times a year
-- The specific ISO-dated key beats the bare year key. That precedence also now
-- applies to the plain note (it used to be year-first); identical for any event
-- that meets once a year, which is every current caller.
function p.pasttable(frame)
local a = frame.args
local parent = frame.getParent and frame:getParent()
local pa = (parent and parent.args) or {}
-- Hand-authored cells: the calling template's args, with any passed straight
-- to #invoke winning, as before.
local cells = {}
for k, v in pairs(pa) do cells[tostring(k)] = v end
for k, v in pairs(a) do cells[tostring(k)] = v end
local function opt(name, default)
local v = pa[name]
if v == nil or tostring(v) == "" then return default end
return v
end
local cols = parseColumns(opt("columns", ""))
local showNumber = truthy(opt("numbered", "no"))
local showYear = truthy(opt("year", "yes"))
local datesHdr = opt("dates", "Dates")
local notesHdr = opt("notes", "Notes")
local showDates = truthy(datesHdr)
local showNotes = truthy(notesHdr)
local oldestFirst = tostring(opt("order", "newest")):lower():find("old") ~= nil
local sortable = truthy(opt("sortable", "yes"))
local caption = opt("caption", "Past occurrences")
local class = opt("class", "wikitable" .. (sortable and " sortable" or ""))
local style = opt("style", "width:100%;")
local ncols = (showNumber and 1 or 0) + (showYear and 1 or 0)
+ (showDates and 1 or 0) + #cols + (showNotes and 1 or 0)
local buf = {}
buf[#buf+1] = '{| class="' .. class .. '"'
.. ((tostring(style) ~= "" and truthy(style)) and (' style="' .. style .. '"') or "")
if truthy(caption) then buf[#buf+1] = "|+ " .. caption end
-- One header cell per line: a label containing "!" or "|" would break the
-- inline "!! " form.
if showNumber then buf[#buf+1] = "! #" end
if showYear then buf[#buf+1] = "! Year" end
if showDates then buf[#buf+1] = "! " .. datesHdr end
for _, c in ipairs(cols) do buf[#buf+1] = "! " .. c.label end
if showNotes then
buf[#buf+1] = "! " .. (sortable and 'class="unsortable" | ' or "") .. notesHdr
end
local occ = p._occurrences(a)
table.sort(occ, function(x, y) return x.s < y.s end) -- chronological
local total = #occ
if total == 0 then
buf[#buf+1] = "|-"
buf[#buf+1] = '| colspan="' .. math.max(ncols, 1) .. "\" | ''No past occurrences recorded.''"
buf[#buf+1] = "|}"
return table.concat(buf, "\n")
end
-- Link a year to <event page>/<year> when that write-up subpage exists (else
-- leave it as plain text — no redlinks). Reading .exists also records a link
-- dependency, so creating or deleting a /YEAR page auto-refreshes this table.
local base = mw.title.getCurrentTitle().prefixedText
local order = {}
for i = 1, total do order[i] = i end
if not oldestFirst then
for i = 1, math.floor(total / 2) do
order[i], order[total + 1 - i] = order[total + 1 - i], order[i]
end
end
for _, i in ipairs(order) do
local o = occ[i]
local y = tostring(fromJDN(o.s))
local iso = fmtJDN(o.s)
local function cell(key)
local v = cells[iso .. "-" .. key]
if v == nil or tostring(v) == "" then v = cells[y .. "-" .. key] end
return v or ""
end
buf[#buf+1] = "|-"
if showNumber then buf[#buf+1] = "| " .. i end
if showYear then
local ycell = y
local sub = mw.title.new(base .. "/" .. y)
if sub and sub.exists then ycell = "[[" .. base .. "/" .. y .. "|" .. y .. "]]" end
buf[#buf+1] = "| " .. ycell
end
-- data-sort-value keeps the Dates column in chronological order when the
-- table is sortable: "August 14 – 16, 2020" would otherwise sort as text,
-- i.e. alphabetically by month name.
if showDates then
buf[#buf+1] = '| data-sort-value="' .. iso .. '" | ' .. humanRange(o)
end
for _, c in ipairs(cols) do buf[#buf+1] = "| " .. cell(c.key) end
if showNotes then buf[#buf+1] = "| " .. (cells[iso] or cells[y] or "") end
end
buf[#buf+1] = "|}"
return table.concat(buf, "\n")
end
return p