Module:Recurrence

From AnOtherWiki, the free encyclopedia written by, for, and about the Otherkin community.

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